diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/aimodels/edit/layout.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/aimodels/edit/layout.tsx index 12b9a5ef..4cda02c8 100644 --- a/app/[locale]/dashboard/[entityType]/[entitySlug]/aimodels/edit/layout.tsx +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/aimodels/edit/layout.tsx @@ -18,11 +18,11 @@ import { Stepper, toast, useStepperStep } from 'opub-ui'; import type { StepperItem } from 'opub-ui'; import { GraphQL } from '@/lib/api'; +import { FetchAIModelForPublish } from './[id]/publish/page'; import { isModelInfoComplete } from './aimodel-summary'; import { WizardHeader } from './components/WizardHeader'; import { EditStatusProvider, useEditStatus } from './context'; import styles from './edit.module.scss'; -import { FetchAIModelForPublish } from './[id]/publish/page'; const UpdateAIModelNameMutation = graphql(` mutation updateAIModelName($input: UpdateAIModelInput!) { @@ -184,7 +184,8 @@ function Wizard({ children }: { children: ReactNode }) { }; const primaryVersion = - model?.versions?.find((version) => version.isLatest) || model?.versions?.[0]; + model?.versions?.find((version) => version.isLatest) || + model?.versions?.[0]; const accessReady = (primaryVersion?.providers?.length ?? 0) > 0; const steps: StepperItem[] = [ @@ -193,8 +194,7 @@ function Wizard({ children }: { children: ReactNode }) { label: 'Versions', description: 'Configure releases and access', icon: IconVersions, - isCompleted: - versionsCompleted || (!completionReady && currentStep > 1), + isCompleted: versionsCompleted || (!completionReady && currentStep > 1), content: stepContent(currentStep === 1, children), }, { @@ -202,8 +202,7 @@ function Wizard({ children }: { children: ReactNode }) { label: 'Model Information', description: 'Describe the model', icon: IconFileDescription, - isCompleted: - infoCompleted || (!completionReady && currentStep > 2), + isCompleted: infoCompleted || (!completionReady && currentStep > 2), content: stepContent(currentStep === 2, children), }, { diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/aimodels/listing.ts b/app/[locale]/dashboard/[entityType]/[entitySlug]/aimodels/listing.ts new file mode 100644 index 00000000..f268241a --- /dev/null +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/aimodels/listing.ts @@ -0,0 +1,128 @@ +import { graphql } from '@/gql'; +import { + AiModelProvider, + AiModelStatus, + AiModelType, +} from '@/gql/generated/graphql'; +import type { SortField } from 'opub-ui'; + +import { + enumOptions, + formatUpdated, + joinNames, + labelFromValue, +} from '../provider-flow/table-listing'; + +export const AI_MODEL_TAB_KEYS: readonly string[] = [ + AiModelStatus.Registered, + AiModelStatus.Active, +]; + +export const AI_MODEL_TABLE_STATUSES = [ + { key: AiModelStatus.Registered, label: 'Draft' }, + { key: AiModelStatus.Active, label: 'Published' }, +]; + +export const INITIAL_AI_MODEL_SORT: SortField[] = [ + { field: 'updatedAt', direction: 'desc' }, +]; + +export const AI_MODEL_FIELD_MAP: Record = { + displayName: 'display_name', + modelType: 'model_type', + geography: 'geographies__id', + sector: 'sectors__id', + updatedAt: 'updated_at', +}; + +const PROVIDER_LABELS: Record = { + [AiModelProvider.Custom]: 'Custom', + [AiModelProvider.Huggingface]: 'Hugging Face', + [AiModelProvider.LlamaCustom]: 'Llama Custom', + [AiModelProvider.LlamaOllama]: 'Ollama', + [AiModelProvider.LlamaReplicate]: 'Replicate', + [AiModelProvider.LlamaTogether]: 'Together', + [AiModelProvider.Openai]: 'OpenAI', +}; + +export const MODEL_TYPE_FILTER_OPTIONS = enumOptions( + Object.values(AiModelType) +); + +export const PROVIDER_FILTER_OPTIONS = enumOptions( + Object.values(AiModelProvider), + PROVIDER_LABELS +); + +export const aiModelsTableQueryDoc = graphql(` + query AIModelsTableListing( + $filters: [FilterSpec!] + $sortOptions: [SortSpec!] + $limit: Int + $offset: Int + ) { + aiModelsTable( + filters: $filters + sortOptions: $sortOptions + limit: $limit + offset: $offset + ) { + totalItemsCount + statusCounts { + status + count + } + data { + id + displayName + modelType + provider + status + updatedAt + sectors { + name + } + geographies { + name + } + } + } + } +`); + +export type AIModelListingRow = { + id: number; + displayName: string; + modelType: string; + provider: string; + sector: string; + geography: string; + status: string; + updatedAt: string; +}; + +type AIModelTableRecord = { + id: number; + displayName: string; + modelType: string; + provider: string; + status: string; + updatedAt: string; + sectors?: Array<{ name?: string | null }> | null; + geographies?: Array<{ name?: string | null }> | null; +}; + +export function toAIModelRows( + models: AIModelTableRecord[] +): AIModelListingRow[] { + return models.map((model) => ({ + id: model.id, + displayName: model.displayName?.trim() || 'Untitled AI Model', + modelType: labelFromValue(model.modelType), + provider: labelFromValue(model.provider, PROVIDER_LABELS), + sector: joinNames(model.sectors), + geography: joinNames(model.geographies), + status: model.status, + updatedAt: formatUpdated(model.updatedAt), + })); +} diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/aimodels/page.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/aimodels/page.tsx index e985e3ba..1d6e7ddb 100644 --- a/app/[locale]/dashboard/[entityType]/[entitySlug]/aimodels/page.tsx +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/aimodels/page.tsx @@ -1,63 +1,47 @@ 'use client'; -import { use, useEffect } from 'react'; -import { useRouter } from 'next/navigation'; +import { useParams, useRouter } from 'next/navigation'; import { graphql } from '@/gql'; import { AiModelProvider, AiModelStatus, AiModelType, - Ordering, } from '@/gql/generated/graphql'; +import { IconCpu } from '@tabler/icons-react'; import { useMutation, useQuery } from '@tanstack/react-query'; -import { parseAsString, useQueryState } from 'nuqs'; -import { Button, DataTable, Icon, IconButton, Text, toast } from 'opub-ui'; -import { twMerge } from 'tailwind-merge'; +import { toast, type ColumnFilterConfig } from 'opub-ui'; import { GraphQL } from '@/lib/api'; -import { formatDate } from '@/lib/utils'; -import { Icons } from '@/components/icons'; -import { LinkButton } from '@/components/Link'; -import { Loading } from '@/components/loading'; -import { ActionBar } from '../provider-flow/ActionBar'; -import { Navigation } from '../provider-flow/Navigation'; - -interface AIModelListItem { - id: number; - displayName: string; - name: string; - modelType: string; - provider: string; - status: string; - createdAt?: string | null; - updatedAt?: string | null; -} - -interface AIModelTableRow { - id: number; - displayName: string; - name: string; - modelType: string; - provider: string; - status: string; - createdAt: string; - updatedAt: string; -} - -const allAIModels = graphql(` - query AIModelsData($filters: AIModelFilter, $order: AIModelOrder) { - aiModels(filters: $filters, order: $order) { - id - displayName - name - modelType - provider - status - createdAt - updatedAt - } - } -`); +import { + listingRowActions, + ListingStatus, + ListingTitle, + ProviderListingTable, +} from '../provider-flow/ProviderListingTable'; +import { + errorMessage, + listingEmptyLabel, + listingOwner, + listingQueryOptions, + listingQuerySettled, + sectorGeographyFilters, + toApiFilters, + toApiSort, + useListingFacets, + useProviderTable, + useSettledStatusCounts, +} from '../provider-flow/table-listing'; +import { + AI_MODEL_FIELD_MAP, + AI_MODEL_TAB_KEYS, + AI_MODEL_TABLE_STATUSES, + AIModelListingRow, + aiModelsTableQueryDoc, + INITIAL_AI_MODEL_SORT, + MODEL_TYPE_FILTER_OPTIONS, + PROVIDER_FILTER_OPTIONS, + toAIModelRows, +} from './listing'; const deleteAIModel = graphql(` mutation deleteAIModel($modelId: Int!) { @@ -81,258 +65,241 @@ const createAIModel = graphql(` } `); -const AIMODELS_ACTION_TOAST_ID = 'aimodels-list-action-toast'; +const unpublishAIModel = graphql(` + mutation unpublishAIModelListing($input: UpdateAIModelInput!) { + updateAiModel(input: $input) { + success + data { + id + status + } + } + } +`); -export default function AIModelsPage({ - params, -}: { - params: Promise<{ entityType: string; entitySlug: string }>; -}) { - const { entityType, entitySlug } = use(params) as { - entityType: string; - entitySlug: string; - }; +const aiModelColumns = [ + { + accessorKey: 'displayName', + header: 'AI Model', + searchable: true, + meta: { truncate: true }, + cell: ({ row }: { row: { original: AIModelListingRow } }) => ( + + ), + }, + { + accessorKey: 'modelType', + header: 'Type', + meta: { optional: true }, + }, + { + accessorKey: 'provider', + header: 'Provider', + meta: { optional: true }, + }, + { + accessorKey: 'sector', + header: 'Sector', + maxSize: 160, + meta: { optional: true, truncate: true }, + }, + { + accessorKey: 'geography', + header: 'Geography', + meta: { optional: true, truncate: true }, + }, + { + accessorKey: 'status', + header: 'Status', + meta: { optional: true }, + cell: ({ row }: { row: { original: AIModelListingRow } }) => ( + + ), + }, + { + accessorKey: 'updatedAt', + header: 'Last Updated', + }, +]; + +export default function AIModelsPage() { const router = useRouter(); + const params = useParams<{ entityType?: string; entitySlug?: string }>(); + const entityType = params?.entityType; + const entitySlug = params?.entitySlug; + const ownerArgs = listingOwner(entityType, entitySlug); + const isValidParams = ownerArgs !== null; - const [navigationTab, setNavigationTab] = useQueryState('tab', parseAsString); + const table = useProviderTable(AI_MODEL_TAB_KEYS, INITIAL_AI_MODEL_SORT); + const facets = useListingFacets(entityType, entitySlug); - const navigationOptions = [ - { - label: 'Draft', - url: `draft`, - selected: navigationTab === 'draft', - }, + const modelsQuery = useQuery( + [ + 'aimodels_table', + entityType, + entitySlug, + table.filters, + table.sorting, + table.pagination, + ], + () => + GraphQL(aiModelsTableQueryDoc, ownerArgs || {}, { + filters: toApiFilters(table.filters, AI_MODEL_FIELD_MAP), + sortOptions: toApiSort(table.sorting, AI_MODEL_FIELD_MAP), + limit: table.pagination.limit, + offset: table.pagination.offset, + }), { - label: 'Published', - url: `published`, - selected: navigationTab === 'published', - }, - // { - // label: 'Approved', - // url: `approved`, - // selected: navigationTab === 'approved', - // }, - ]; + enabled: isValidParams, + ...listingQueryOptions, + } + ); - const AllAIModels = useQuery( - [`fetch_AIModels`, navigationTab], - () => - GraphQL( - allAIModels, - { - [entityType]: entitySlug, - }, - { - filters: { - status: - navigationTab === 'published' - ? AiModelStatus.Active - : navigationTab === 'draft' - ? AiModelStatus.Registered - : AiModelStatus.Approved, - }, - order: { updatedAt: Ordering.Desc }, - } - ) + const statusCounts = useSettledStatusCounts( + modelsQuery.data?.aiModelsTable.statusCounts, + listingQuerySettled(modelsQuery), + AI_MODEL_TAB_KEYS ); - useEffect(() => { - if (navigationTab === null || navigationTab === undefined) - setNavigationTab('draft'); - AllAIModels.refetch(); - }, [AllAIModels, navigationTab, setNavigationTab]); + const deleteMutation = useMutation( + (id: number) => GraphQL(deleteAIModel, ownerArgs || {}, { modelId: id }), + { + onSuccess: (result) => { + if (!result.deleteAiModel.success) { + toast('Error: Could not delete the AI model'); + return; + } + toast('Deleted AI model successfully'); + modelsQuery.refetch(); + }, + onError: (err: unknown) => { + toast(`Error: ${errorMessage(err)}`); + }, + } + ); - const DeleteAIModelMutation = useMutation( - [`delete_AIModel`], - (data: { id: number }) => - GraphQL( - deleteAIModel, - { - [entityType]: entitySlug, + const unpublishMutation = useMutation( + (id: number) => + GraphQL(unpublishAIModel, ownerArgs || {}, { + input: { + id, + status: AiModelStatus.Registered, + isPublic: false, + isActive: false, }, - { modelId: data.id } - ), + }), { - onSuccess: () => { - toast(`Deleted AI Model successfully`, { - id: AIMODELS_ACTION_TOAST_ID, - }); - AllAIModels.refetch(); + onSuccess: (result) => { + if (!result.updateAiModel.success) { + toast('Error: Could not unpublish the AI model'); + return; + } + toast('Unpublished AI model successfully'); + modelsQuery.refetch(); }, onError: (err: unknown) => { - toast('Error: ' + (typeof err === 'object' && err !== null && 'message' in err && typeof err.message === 'string' ? err.message : String(err)).split(':')[0], { - id: AIMODELS_ACTION_TOAST_ID, - }); + toast(`Error: ${errorMessage(err)}`); }, } ); - const CreateAIModelMutation = useMutation( - [`create_AIModel`], + const createMutation = useMutation( () => - GraphQL( - createAIModel, - { - [entityType]: entitySlug, + GraphQL(createAIModel, ownerArgs || {}, { + input: { + name: `new-model-${Date.now()}`, + displayName: 'New AI Model', + modelType: AiModelType.TextGeneration, + provider: AiModelProvider.Custom, }, - { - input: { - name: `new-model-${Date.now()}`, - displayName: 'New AI Model', - modelType: AiModelType.TextGeneration, - provider: AiModelProvider.Custom, - }, - } - ), + }), { onSuccess: (data) => { const newModelId = data.createAiModel.data?.id; - toast(`Created AI Model successfully`, { - id: AIMODELS_ACTION_TOAST_ID, - }); + if ( + !data.createAiModel.success || + !newModelId || + !entityType || + !entitySlug + ) { + toast('Error: Could not create the AI model'); + return; + } + toast('AI model created successfully!'); router.push( `/dashboard/${entityType}/${entitySlug}/aimodels/edit/${newModelId}/versions` ); }, onError: (err: unknown) => { - toast('Error: ' + (typeof err === 'object' && err !== null && 'message' in err && typeof err.message === 'string' ? err.message : String(err)).split(':')[0], { - id: AIMODELS_ACTION_TOAST_ID, - }); + toast(`Error: ${errorMessage(err)}`); }, } ); - const handleCreateNewModel = () => { - CreateAIModelMutation.mutate(); - }; - - const modelsListColumns = [ + const filterConfig: ColumnFilterConfig[] = [ { - accessorKey: 'displayName', - header: 'Display Name', - cell: ({ row }: { row: { original: AIModelTableRow } }) => ( - - - {row.original.displayName} - - - ), + columnId: 'modelType', + type: 'select', + options: MODEL_TYPE_FILTER_OPTIONS, }, - // { - // accessorKey: 'name', - // header: 'Model Name', - // cell: ({ row }: { row: { original: AIModelTableRow } }) => ( - // - // {row.original.name} - // - // ), - // }, { - accessorKey: 'modelType', - header: 'Type', - cell: ({ row }: { row: { original: AIModelTableRow } }) => ( - {row.original.modelType} - ), - }, - { - accessorKey: 'provider', - header: 'Provider', - cell: ({ row }: { row: { original: AIModelTableRow } }) => ( - {row.original.provider} - ), - }, - { accessorKey: 'createdAt', header: 'Date Created' }, - { accessorKey: 'updatedAt', header: 'Last Updated' }, - { - accessorKey: 'delete', - header: 'Delete', - cell: ({ row }: { row: { original: AIModelTableRow } }) => ( - { - DeleteAIModelMutation.mutate({ - id: row.original?.id, - }); - }} - > - Delete - - ), + columnId: 'provider', + type: 'select', + options: PROVIDER_FILTER_OPTIONS, }, + ...sectorGeographyFilters(facets), ]; - const generateTableData = (list: AIModelListItem[]) => { - return list.map((item) => { - return { - id: item.id, - displayName: item.displayName, - name: item.name, - modelType: item.modelType, - provider: item.provider, - status: item.status, - createdAt: formatDate(item.createdAt ?? null) || '', - updatedAt: formatDate(item.updatedAt ?? null) || '', - }; - }); - }; - - return ( - <> -
- + if (!isValidParams || !entityType || !entitySlug) { + return null; + } - {AllAIModels.data?.aiModels && AllAIModels.data.aiModels.length > 0 ? ( -
- item.selected)?.label || '' - } - primaryAction={{ - content: 'Add New AI Model', - onAction: handleCreateNewModel, - }} - /> + const rows = toAIModelRows(modelsQuery.data?.aiModelsTable.data ?? []); - -
- ) : AllAIModels.isLoading ? ( - - ) : ( - <> -
-
- - - You have not added any AI models yet. - - -
-
- - )} -
- + return ( + { + if (!createMutation.isLoading) createMutation.mutate(); + }} + placeholder="Search AI models..." + columns={aiModelColumns} + rows={rows} + sortColumns={['displayName', 'modelType', 'provider', 'updatedAt']} + filters={filterConfig} + statuses={AI_MODEL_TABLE_STATUSES} + emptyLabel={listingEmptyLabel( + table.filters, + table.status, + 'No AI models yet' + )} + loading={modelsQuery.isFetching} + loadError={modelsQuery.isError && !modelsQuery.isFetching} + onRetry={() => modelsQuery.refetch()} + totalRows={modelsQuery.data?.aiModelsTable.totalItemsCount ?? 0} + statusCounts={statusCounts} + table={table} + getRowActions={(row: AIModelListingRow) => { + const published = row.status === AiModelStatus.Active; + return listingRowActions({ + published, + title: row.displayName, + noun: 'AI model', + onEdit: () => + router.push( + `/dashboard/${entityType}/${entitySlug}/aimodels/edit/${row.id}` + ), + onUnpublish: () => unpublishMutation.mutate(row.id), + onDelete: () => deleteMutation.mutate(row.id), + }); + }} + /> ); } diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/charts/[chartID]/edit/build/page.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/charts/[chartID]/edit/build/page.tsx new file mode 100644 index 00000000..89c15c99 --- /dev/null +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/charts/[chartID]/edit/build/page.tsx @@ -0,0 +1,258 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import { useQuery } from '@tanstack/react-query'; +import { Spinner, toast } from 'opub-ui'; + +import { GraphQL } from '@/lib/api'; +import { BuildStep } from '../../../wizard/BuildStep'; +import { useChartCatalog } from '../../../wizard/catalog'; +import { useChartEditStatus } from '../../../wizard/context'; +import { + chartImageCreateDoc, + chartWizardDetailsDoc, + chartWizardSaveDoc, +} from '../../../wizard/documents'; +import { + builderFromChart, + chartsBasePath, + emptyBuilder, + entityHeaders, + hasPreviewOptions, + isBuilderValid, + vizChartInput, + type BuilderState, +} from '../../../wizard/model'; + +export default function ChartBuildPage() { + const params = useParams<{ + entityType: string; + entitySlug: string; + chartID: string; + }>(); + const router = useRouter(); + const headers = entityHeaders(params.entityType, params.entitySlug); + const { + setStatus, + setBuildComplete, + setTitleOverride, + registerTitleSave, + registerBeforeNavigateHandler, + stepShowErrors, + } = useChartEditStatus(); + + const [state, setState] = useState(emptyBuilder); + const [preview, setPreview] = useState | null>(null); + const [showErrors, setShowErrors] = useState(false); + const [saving, setSaving] = useState(false); + const [imageFile, setImageFile] = useState(null); + const [localPreview, setLocalPreview] = useState(null); + const previewRef = useRef(null); + const hydrated = useRef(false); + const skipAutosave = useRef(true); + const creatingImage = useRef(false); + const stateRef = useRef(state); + const imageFileRef = useRef(imageFile); + stateRef.current = state; + imageFileRef.current = imageFile; + + const details = useQuery([`chart-wizard-${params.chartID}`], () => + GraphQL(chartWizardDetailsDoc, headers, { + chartDetailsId: params.chartID, + }) + ); + + const catalog = useChartCatalog( + params.entityType, + params.entitySlug, + state.datasetId, + state.resourceId + ); + + useEffect(() => { + const chart = details.data?.resourceChart; + if (!chart || hydrated.current) return; + hydrated.current = true; + const next = builderFromChart(chart); + setState(next); + setTitleOverride(next.name); + setBuildComplete(isBuilderValid(next, false)); + const options = chart.chart?.options; + if (hasPreviewOptions(options)) { + setPreview(options as Record); + } + }, [details.data, setBuildComplete, setTitleOverride]); + + const persist = async (next: BuilderState) => { + if (!next.resourceId || next.selection === 'IMAGE') return; + setSaving(true); + setStatus('loading'); + try { + const result = await GraphQL(chartWizardSaveDoc, headers, { + chartInput: vizChartInput(next, params.chartID), + }); + const saved = result.editResourceChart; + const options = + saved.__typename === 'TypeResourceChart' ? saved.chart?.options : null; + if (hasPreviewOptions(options)) { + setPreview(options as Record); + } + setStatus('success'); + } catch (error) { + setStatus('success'); + const message = error instanceof Error ? error.message : String(error); + toast(`Could not save the chart. ${message}`); + throw error; + } finally { + setSaving(false); + } + }; + + const createImageChart = async (file: File) => { + const datasetId = stateRef.current.datasetId; + if (!datasetId) { + toast('Choose a dataset before uploading an image.'); + throw new Error('Dataset is required'); + } + if (creatingImage.current) return false; + creatingImage.current = true; + setSaving(true); + setStatus('loading'); + try { + const result = await GraphQL(chartImageCreateDoc, headers, { + input: { + dataset: datasetId, + image: file, + }, + }); + const created = result.createResourceChartImage; + if (!created || created.__typename !== 'TypeResourceChartImage') { + throw new Error('Could not create the chart image'); + } + toast('Chart image created'); + router.push( + `${chartsBasePath(params.entityType, params.entitySlug)}/image/${created.id}/edit/build` + ); + return false; + } catch (error) { + creatingImage.current = false; + const message = error instanceof Error ? error.message : String(error); + if (message !== 'Dataset is required') { + toast(`Could not upload the chart image. ${message}`); + } + throw error; + } finally { + setSaving(false); + setStatus('success'); + } + }; + + const handleImageFile = (file: File | null) => { + setImageFile(file); + if (previewRef.current) URL.revokeObjectURL(previewRef.current); + const next = file ? URL.createObjectURL(file) : null; + previewRef.current = next; + setLocalPreview(next); + }; + + useEffect(() => { + if (!hydrated.current) return; + if (skipAutosave.current) { + skipAutosave.current = false; + return; + } + const timer = window.setTimeout(() => { + void persist(stateRef.current).catch(() => undefined); + }, 700); + return () => window.clearTimeout(timer); + // persist reads the latest state through the ref. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [state]); + + useEffect(() => { + setBuildComplete(isBuilderValid(state, Boolean(imageFile))); + }, [imageFile, setBuildComplete, state]); + + useEffect(() => { + registerTitleSave((title) => { + setTitleOverride(title); + setState((current) => ({ ...current, name: title })); + }); + registerBeforeNavigateHandler(async () => { + const current = stateRef.current; + const file = imageFileRef.current; + if (!isBuilderValid(current, Boolean(file))) { + setShowErrors(true); + throw new Error('Chart configuration is incomplete'); + } + if (current.selection === 'IMAGE' && file) { + await createImageChart(file); + return false; + } + await persist(current); + }); + return () => registerBeforeNavigateHandler(null); + }); + + const fallbackFields = + state.resourceId && + state.resourceId === details.data?.resourceChart?.resource?.id + ? (details.data?.resourceChart?.resource?.schema?.map((field) => ({ + id: field.id, + fieldName: field.fieldName, + format: field.format ?? '', + })) ?? []) + : []; + const fields = catalog.fields.length > 0 ? catalog.fields : fallbackFields; + + if (details.isLoading || !hydrated.current) { + return ( +
+ +
+ ); + } + + if (details.isError) { + return
Could not load this chart.
; + } + + return ( + setState((current) => ({ ...current, ...patch }))} + datasets={catalog.datasets} + datasetsLoading={catalog.datasetsLoading} + fields={fields} + fieldsLoading={catalog.fieldsLoading} + hasImage={Boolean(imageFile)} + imageName={imageFile?.name} + remoteImageUrl={localPreview} + previewOptions={preview} + showErrors={showErrors || stepShowErrors} + saving={saving} + selections={['BAR', 'LINE', 'MAP', 'BIG_NUMBER', 'IMAGE', 'TREEMAP']} + onImageFile={handleImageFile} + onSave={() => { + if (state.selection === 'IMAGE') { + if (!imageFile || !isBuilderValid(state, true)) { + setShowErrors(true); + toast('Choose a dataset and an image before saving.'); + return; + } + void createImageChart(imageFile).catch(() => undefined); + return; + } + if (!isBuilderValid(state, false)) { + setShowErrors(true); + toast('Add the required fields before saving.'); + return; + } + void persist(state) + .then(() => toast('Chart saved')) + .catch(() => undefined); + }} + /> + ); +} diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/charts/[chartID]/edit/layout.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/charts/[chartID]/edit/layout.tsx new file mode 100644 index 00000000..46bce749 --- /dev/null +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/charts/[chartID]/edit/layout.tsx @@ -0,0 +1,14 @@ +'use client'; + +import { ReactNode } from 'react'; + +import { ChartEditFrame } from '../../wizard/ChartEditFrame'; +import { ChartEditStatusProvider } from '../../wizard/context'; + +export default function ChartEditLayout({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/charts/[chartID]/edit/publish/page.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/charts/[chartID]/edit/publish/page.tsx new file mode 100644 index 00000000..0ef80e3d --- /dev/null +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/charts/[chartID]/edit/publish/page.tsx @@ -0,0 +1,193 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import { useParams } from 'next/navigation'; +import { useQuery } from '@tanstack/react-query'; +import ReactECharts from 'echarts-for-react'; +import { Spinner, Text, toast } from 'opub-ui'; + +import { GraphQL } from '@/lib/api'; +import { useChartCatalog } from '../../../wizard/catalog'; +import { useChartEditStatus } from '../../../wizard/context'; +import { + chartWizardDetailsDoc, + chartWizardPublishDoc, + chartWizardSaveDoc, +} from '../../../wizard/documents'; +import { ReviewStep } from '../../../wizard/ReviewStep'; +import { + builderFromChart, + chartsBasePath, + configRows, + entityHeaders, + fieldById, + hasPreviewOptions, + isBuilderValid, + vizChartInput, + chartTypeLabel, +} from '../../../wizard/model'; + +export default function ChartPublishPage() { + const params = useParams<{ + entityType: string; + entitySlug: string; + chartID: string; + }>(); + const headers = entityHeaders(params.entityType, params.entitySlug); + const { + setStatus, + setTitleOverride, + registerTitleSave, + registerBeforeNavigateHandler, + setBuildComplete, + } = useChartEditStatus(); + const [name, setName] = useState(''); + const nameReady = useRef(false); + const [saving, setSaving] = useState(false); + const [publishing, setPublishing] = useState(false); + + const details = useQuery([`chart-wizard-${params.chartID}`], () => + GraphQL(chartWizardDetailsDoc, headers, { + chartDetailsId: params.chartID, + }) + ); + + const chart = details.data?.resourceChart; + const state = chart ? { ...builderFromChart(chart), name } : null; + + useEffect(() => { + if (!chart || nameReady.current) return; + nameReady.current = true; + setName(chart.name ?? ''); + setTitleOverride(chart.name ?? ''); + setBuildComplete(isBuilderValid(builderFromChart(chart), false)); + }, [chart, setBuildComplete, setTitleOverride]); + + const catalog = useChartCatalog( + params.entityType, + params.entitySlug, + state?.datasetId ?? '', + state?.resourceId ?? '' + ); + + const datasetTitle = + catalog.datasets.find((dataset) => dataset.id === state?.datasetId)?.title || + chart?.dataset?.title || + ''; + const resourceName = + catalog.datasets + .find((dataset) => dataset.id === state?.datasetId) + ?.resources.find((resource) => resource.id === state?.resourceId)?.name || + chart?.resource?.name || + ''; + + const save = async (nextName: string) => { + if (!state) return; + const next = { ...state, name: nextName }; + setSaving(true); + setStatus('loading'); + try { + await GraphQL(chartWizardSaveDoc, headers, { + chartInput: vizChartInput(next, params.chartID), + }); + setStatus('success'); + await details.refetch(); + } catch (error) { + setStatus('success'); + const message = error instanceof Error ? error.message : String(error); + toast(`Could not save the chart. ${message}`); + throw error; + } finally { + setSaving(false); + } + }; + + useEffect(() => { + registerTitleSave((title) => { + setName(title); + setTitleOverride(title); + void save(title).catch(() => undefined); + }); + registerBeforeNavigateHandler(async () => { + await save(name); + }); + return () => registerBeforeNavigateHandler(null); + // save closes over the latest chart once it has loaded. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [chart?.id, name]); + + if (details.isLoading || !state || !chart) { + return ( +
+ +
+ ); + } + + const previewReady = hasPreviewOptions(chart.chart?.options); + const namedState = { ...state, name }; + const valid = isBuilderValid(namedState, false); + const labelFor = (id: string) => + fieldById(catalog.fields, id)?.fieldName || + chart.resource?.schema?.find((field) => field.id === id)?.fieldName || + id; + + return ( + { + setName(value); + setTitleOverride(value); + }} + preview={ + previewReady ? ( + } + style={{ height: 360 }} + /> + ) : ( + No preview yet. Finish the chart configuration first. + ) + } + checks={[ + { label: 'Dataset selected', ok: Boolean(namedState.datasetId) }, + { label: 'File selected', ok: Boolean(namedState.resourceId) }, + { label: 'Chart type selected', ok: Boolean(namedState.selection) }, + { label: 'Configuration valid', ok: valid }, + { label: 'Chart preview generated', ok: previewReady }, + { label: 'Chart name added', ok: Boolean(name.trim()) }, + ]} + editBase={`${chartsBasePath(params.entityType, params.entitySlug)}/${params.chartID}/edit`} + saving={saving} + publishing={publishing} + onSave={() => { + void save(name) + .then(() => toast('Chart saved')) + .catch(() => undefined); + }} + onPublish={() => { + if (!valid || !name.trim() || !previewReady) { + toast('This chart is not ready to publish.'); + return; + } + setPublishing(true); + void save(name) + .then(() => + GraphQL(chartWizardPublishDoc, headers, { + chartId: params.chartID, + }) + ) + .then(() => toast('Chart published')) + .catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + toast(`Could not publish the chart. ${message}`); + }) + .finally(() => setPublishing(false)); + }} + /> + ); +} diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/charts/[chartID]/page.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/charts/[chartID]/page.tsx index ad1b775f..6c4e624a 100644 --- a/app/[locale]/dashboard/[entityType]/[entitySlug]/charts/[chartID]/page.tsx +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/charts/[chartID]/page.tsx @@ -1,30 +1,32 @@ 'use client'; -import { useParams, useSearchParams } from 'next/navigation'; +import { useEffect } from 'react'; +import { useParams, useRouter, useSearchParams } from 'next/navigation'; +import { Spinner } from 'opub-ui'; -import ChartGenVizPreview from './components/ChartGenVizPreview'; -import ChartImagePreview from './components/ChartImagePreview'; - -const ChartDetails = () => { - /* - Chart preview page to edit chart details and publish the chart - */ +import { chartsBasePath } from '../wizard/model'; +export default function ChartDetailsRedirect() { const params = useParams<{ entityType: string; entitySlug: string; chartID: string; }>(); - const searchParams = useSearchParams(); - - const chartPreviewType = searchParams.get('type'); - - return chartPreviewType === 'TypeResourceChartImage' ? ( - - ) : ( - + const router = useRouter(); + + useEffect(() => { + const base = chartsBasePath(params.entityType, params.entitySlug); + const destination = + searchParams.get('type') === 'TypeResourceChartImage' + ? `${base}/image/${params.chartID}/edit/build` + : `${base}/${params.chartID}/edit/build`; + router.replace(destination); + }, [params.chartID, params.entitySlug, params.entityType, router, searchParams]); + + return ( +
+ +
); -}; - -export default ChartDetails; +} diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/charts/components/ChartsList.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/charts/components/ChartsList.tsx index fc1fb36d..f0ed4659 100644 --- a/app/[locale]/dashboard/[entityType]/[entitySlug]/charts/components/ChartsList.tsx +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/charts/components/ChartsList.tsx @@ -15,7 +15,7 @@ import { import { GraphQL } from '@/lib/api'; import { toTitleCase } from '@/lib/utils'; import { Icons } from '@/components/icons'; -import ChartEditor from './ChartEditor'; +import { chartsBasePath } from '../wizard/model'; interface ChartsListProps { setType: (type: string) => void; @@ -101,8 +101,7 @@ const ChartsList: React.FC = () => { }>(); const router = useRouter(); - - const [editorView, setEditorView] = useState(false); + const chartsBase = chartsBasePath(params.entityType, params.entitySlug); const chartListRes = useQuery([`chartList`], () => GraphQL( @@ -172,13 +171,9 @@ const ChartsList: React.FC = () => { const handleChart = (row: ChartTableCellContext['row']) => { if (row.original.typename === 'TypeResourceChart') { - router.push( - `/dashboard/${params.entityType}/${params.entitySlug}/charts/${row.original.id}?type=TypeResourceChart` - ); + router.push(`${chartsBase}/${row.original.id}/edit/build`); } else { - router.push( - `/dashboard/${params.entityType}/${params.entitySlug}/charts/${row.original.id}?type=TypeResourceChartImage` - ); + router.push(`${chartsBase}/image/${row.original.id}/edit/build`); } }; @@ -265,9 +260,7 @@ const ChartsList: React.FC = () => { return ( <> - {editorView ? ( - - ) : chartListRes.isLoading || deleteResourceChartmutation.isLoading ? ( + {chartListRes.isLoading || deleteResourceChartmutation.isLoading ? (
@@ -285,7 +278,9 @@ const ChartsList: React.FC = () => { onChange={(e) => handleSearchChange(e)} />
- +
{filteredRows.length > 0 ? ( diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/charts/image/[imageID]/edit/build/page.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/charts/image/[imageID]/edit/build/page.tsx new file mode 100644 index 00000000..faca89dd --- /dev/null +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/charts/image/[imageID]/edit/build/page.tsx @@ -0,0 +1,219 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import { useQuery } from '@tanstack/react-query'; +import { Spinner, toast } from 'opub-ui'; + +import { GraphQL } from '@/lib/api'; +import { BuildStep } from '../../../../wizard/BuildStep'; +import { imageSource, useChartCatalog } from '../../../../wizard/catalog'; +import { useChartEditStatus } from '../../../../wizard/context'; +import { + chartImageDetailsDoc, + chartImageUpdateDoc, + chartWizardCreateDoc, +} from '../../../../wizard/documents'; +import { + chartsBasePath, + emptyBuilder, + entityHeaders, + isBuilderValid, + vizChartInput, + type BuilderState, +} from '../../../../wizard/model'; + +export default function ChartImageBuildPage() { + const params = useParams<{ + entityType: string; + entitySlug: string; + imageID: string; + }>(); + const router = useRouter(); + const headers = entityHeaders(params.entityType, params.entitySlug); + const { + setStatus, + setBuildComplete, + setTitleOverride, + registerTitleSave, + registerBeforeNavigateHandler, + stepShowErrors, + } = useChartEditStatus(); + + const [state, setState] = useState({ + ...emptyBuilder(), + selection: 'IMAGE', + }); + const [imageFile, setImageFile] = useState(null); + const [remoteUrl, setRemoteUrl] = useState(null); + const [showErrors, setShowErrors] = useState(false); + const [saving, setSaving] = useState(false); + const [localPreview, setLocalPreview] = useState(null); + const previewRef = useRef(null); + const hydrated = useRef(false); + const creatingViz = useRef(false); + const stateRef = useRef(state); + const fileRef = useRef(imageFile); + stateRef.current = state; + fileRef.current = imageFile; + + const details = useQuery([`chart-image-${params.imageID}`], () => + GraphQL(chartImageDetailsDoc, headers, { imageId: params.imageID }) + ); + + const catalog = useChartCatalog( + params.entityType, + params.entitySlug, + state.datasetId, + state.resourceId + ); + + useEffect(() => { + const image = details.data?.resourceChartImage; + if (!image) return; + setRemoteUrl(imageSource(image.image?.url)); + if (hydrated.current) return; + hydrated.current = true; + const next: BuilderState = { + ...emptyBuilder(), + selection: 'IMAGE', + name: image.name ?? '', + datasetId: image.dataset?.id ?? '', + }; + setState(next); + setTitleOverride(next.name); + setBuildComplete(isBuilderValid(next, Boolean(image.image?.url))); + }, [details.data, setBuildComplete, setTitleOverride]); + + const hasImage = Boolean(imageFile || remoteUrl); + + const persist = async (next: BuilderState, file: File | null) => { + if (!next.datasetId) return; + setSaving(true); + setStatus('loading'); + try { + await GraphQL(chartImageUpdateDoc, headers, { + input: { + id: params.imageID, + dataset: next.datasetId, + ...(next.name.trim() ? { name: next.name.trim() } : {}), + ...(file ? { image: file } : {}), + }, + }); + setStatus('success'); + if (file) setImageFile(null); + await details.refetch(); + } catch (error) { + setStatus('success'); + const message = error instanceof Error ? error.message : String(error); + toast(`Could not save the chart image. ${message}`); + throw error; + } finally { + setSaving(false); + } + }; + + const createVizChart = async (next: BuilderState) => { + if (creatingViz.current) return false; + creatingViz.current = true; + setSaving(true); + setStatus('loading'); + try { + const result = await GraphQL(chartWizardCreateDoc, headers, { + chartInput: vizChartInput(next), + }); + const created = result.createResourceChart; + if (!created || created.__typename !== 'TypeResourceChart') { + throw new Error('Could not create the chart'); + } + router.push( + `${chartsBasePath(params.entityType, params.entitySlug)}/${created.id}/edit/build` + ); + return false; + } catch (error) { + creatingViz.current = false; + const message = error instanceof Error ? error.message : String(error); + toast(`Could not create the chart. ${message}`); + throw error; + } finally { + setSaving(false); + setStatus('success'); + } + }; + + const handleImageFile = (file: File | null) => { + setImageFile(file); + if (previewRef.current) URL.revokeObjectURL(previewRef.current); + const next = file ? URL.createObjectURL(file) : null; + previewRef.current = next; + setLocalPreview(next); + }; + + useEffect(() => { + setBuildComplete(isBuilderValid(state, hasImage)); + }, [hasImage, setBuildComplete, state]); + + useEffect(() => { + registerTitleSave((title) => { + setTitleOverride(title); + setState((current) => ({ ...current, name: title })); + }); + registerBeforeNavigateHandler(async () => { + const current = stateRef.current; + const file = fileRef.current; + if (!isBuilderValid(current, Boolean(file || remoteUrl))) { + setShowErrors(true); + throw new Error('Chart configuration is incomplete'); + } + if (current.selection !== 'IMAGE') { + await createVizChart(current); + return false; + } + await persist(current, file); + }); + return () => registerBeforeNavigateHandler(null); + }); + + if (details.isLoading || !hydrated.current) { + return ( +
+ +
+ ); + } + + return ( + setState((current) => ({ ...current, ...patch }))} + datasets={catalog.datasets} + datasetsLoading={catalog.datasetsLoading} + fields={catalog.fields} + fieldsLoading={catalog.fieldsLoading} + hasImage={hasImage} + imageName={imageFile?.name || details.data?.resourceChartImage?.image?.name} + remoteImageUrl={localPreview || remoteUrl} + showErrors={showErrors || stepShowErrors} + saving={saving} + onImageFile={handleImageFile} + onSave={() => { + if (!isBuilderValid(state, hasImage)) { + setShowErrors(true); + toast( + state.selection === 'IMAGE' + ? 'Choose a dataset and an image before saving.' + : 'Add the required fields before saving.' + ); + return; + } + if (state.selection !== 'IMAGE') { + void createVizChart(state).catch(() => undefined); + return; + } + void persist(state, imageFile) + .then(() => toast('Chart image saved')) + .catch(() => undefined); + }} + /> + ); +} diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/charts/image/[imageID]/edit/layout.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/charts/image/[imageID]/edit/layout.tsx new file mode 100644 index 00000000..ab0334ce --- /dev/null +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/charts/image/[imageID]/edit/layout.tsx @@ -0,0 +1,18 @@ +'use client'; + +import { ReactNode } from 'react'; + +import { ChartEditFrame } from '../../../wizard/ChartEditFrame'; +import { ChartEditStatusProvider } from '../../../wizard/context'; + +export default function ChartImageEditLayout({ + children, +}: { + children: ReactNode; +}) { + return ( + + {children} + + ); +} diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/charts/image/[imageID]/edit/publish/page.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/charts/image/[imageID]/edit/publish/page.tsx new file mode 100644 index 00000000..f541c662 --- /dev/null +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/charts/image/[imageID]/edit/publish/page.tsx @@ -0,0 +1,171 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import { useParams } from 'next/navigation'; +import { useQuery } from '@tanstack/react-query'; +import { Spinner, Text, toast } from 'opub-ui'; + +import { GraphQL } from '@/lib/api'; +import { imageSource, useChartCatalog } from '../../../../wizard/catalog'; +import { useChartEditStatus } from '../../../../wizard/context'; +import { + chartImageDetailsDoc, + chartImagePublishDoc, + chartImageUpdateDoc, +} from '../../../../wizard/documents'; +import { ReviewStep } from '../../../../wizard/ReviewStep'; +import { chartsBasePath, entityHeaders } from '../../../../wizard/model'; + +export default function ChartImagePublishPage() { + const params = useParams<{ + entityType: string; + entitySlug: string; + imageID: string; + }>(); + const headers = entityHeaders(params.entityType, params.entitySlug); + const { + setStatus, + setTitleOverride, + registerTitleSave, + registerBeforeNavigateHandler, + setBuildComplete, + } = useChartEditStatus(); + const [name, setName] = useState(''); + const [saving, setSaving] = useState(false); + const [publishing, setPublishing] = useState(false); + const hydrated = useRef(false); + + const details = useQuery([`chart-image-${params.imageID}`], () => + GraphQL(chartImageDetailsDoc, headers, { imageId: params.imageID }) + ); + const image = details.data?.resourceChartImage; + const catalog = useChartCatalog( + params.entityType, + params.entitySlug, + image?.dataset?.id ?? '', + '' + ); + + useEffect(() => { + if (!image || hydrated.current) return; + hydrated.current = true; + setName(image.name ?? ''); + setTitleOverride(image.name ?? ''); + setBuildComplete(Boolean(image.dataset?.id && image.image?.url && image.name)); + }, [image, setBuildComplete, setTitleOverride]); + + const datasetTitle = + catalog.datasets.find((dataset) => dataset.id === image?.dataset?.id)?.title || + image?.dataset?.title || + ''; + const previewUrl = imageSource(image?.image?.url); + + const save = async (nextName: string) => { + if (!image?.dataset?.id) return; + setSaving(true); + setStatus('loading'); + try { + await GraphQL(chartImageUpdateDoc, headers, { + input: { + id: params.imageID, + dataset: image.dataset.id, + name: nextName.trim(), + }, + }); + setStatus('success'); + await details.refetch(); + } catch (error) { + setStatus('success'); + const message = error instanceof Error ? error.message : String(error); + toast(`Could not save the chart image. ${message}`); + throw error; + } finally { + setSaving(false); + } + }; + + useEffect(() => { + registerTitleSave((title) => { + setName(title); + setTitleOverride(title); + void save(title).catch(() => undefined); + }); + registerBeforeNavigateHandler(async () => { + if (image?.dataset?.id) await save(name); + }); + return () => registerBeforeNavigateHandler(null); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [image?.id, name]); + + if (details.isLoading || !image) { + return ( +
+ +
+ ); + } + + const readyName = Boolean(name.trim()); + const readyImage = Boolean(previewUrl); + const readyDataset = Boolean(image.dataset?.id); + + return ( + { + setName(value); + setTitleOverride(value); + }} + preview={ + previewUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + {name + ) : ( + No image uploaded yet. + ) + } + checks={[ + { label: 'Dataset selected', ok: readyDataset }, + { label: 'Image uploaded', ok: readyImage }, + { label: 'Chart type selected', ok: true }, + { label: 'Configuration valid', ok: readyDataset && readyImage }, + { label: 'Chart preview generated', ok: readyImage }, + { label: 'Chart name added', ok: readyName }, + ]} + editBase={`${chartsBasePath(params.entityType, params.entitySlug)}/image/${params.imageID}/edit`} + saving={saving} + publishing={publishing} + onSave={() => { + void save(name) + .then(() => toast('Chart image saved')) + .catch(() => undefined); + }} + onPublish={() => { + if (!readyDataset || !readyImage || !readyName) { + toast('This chart image is not ready to publish.'); + return; + } + setPublishing(true); + void save(name) + .then(() => + GraphQL(chartImagePublishDoc, headers, { + resourceChartImageId: params.imageID, + }) + ) + .then(() => toast('Chart image published')) + .catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + toast(`Could not publish the chart image. ${message}`); + }) + .finally(() => setPublishing(false)); + }} + /> + ); +} diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/charts/new/page.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/charts/new/page.tsx new file mode 100644 index 00000000..2dcdeca6 --- /dev/null +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/charts/new/page.tsx @@ -0,0 +1,134 @@ +'use client'; + +import { useRef, useState } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import { toast } from 'opub-ui'; + +import { GraphQL } from '@/lib/api'; +import { BuildStep } from '../wizard/BuildStep'; +import { useChartCatalog } from '../wizard/catalog'; +import { ChartWizardShell } from '../wizard/ChartWizardShell'; +import { chartImageCreateDoc, chartWizardCreateDoc } from '../wizard/documents'; +import { + chartsBasePath, + emptyBuilder, + entityHeaders, + isBuilderValid, + vizChartInput, + type BuilderState, +} from '../wizard/model'; + +export default function NewChartPage() { + const params = useParams<{ entityType: string; entitySlug: string }>(); + const router = useRouter(); + const headers = entityHeaders(params.entityType, params.entitySlug); + const [state, setState] = useState(emptyBuilder); + const [imageFile, setImageFile] = useState(null); + const [localPreview, setLocalPreview] = useState(null); + const previewRef = useRef(null); + const [status, setStatus] = useState<'loading' | 'success'>('success'); + const [showErrors, setShowErrors] = useState(false); + const [saving, setSaving] = useState(false); + const creating = useRef(false); + + const catalog = useChartCatalog( + params.entityType, + params.entitySlug, + state.datasetId, + state.resourceId + ); + const base = chartsBasePath(params.entityType, params.entitySlug); + const hasImage = Boolean(imageFile); + + const handleImageFile = (file: File | null) => { + setImageFile(file); + if (previewRef.current) URL.revokeObjectURL(previewRef.current); + const next = file ? URL.createObjectURL(file) : null; + previewRef.current = next; + setLocalPreview(next); + }; + + const createChart = async (destination: 'build' | 'publish') => { + if (creating.current) return; + if (!isBuilderValid(state, hasImage)) { + setShowErrors(true); + toast('Fill in the required chart fields before continuing.'); + throw new Error('Chart configuration is incomplete'); + } + creating.current = true; + setSaving(true); + setStatus('loading'); + try { + if (state.selection === 'IMAGE') { + if (!imageFile) throw new Error('Choose an image'); + const result = await GraphQL(chartImageCreateDoc, headers, { + input: { + dataset: state.datasetId, + image: imageFile, + ...(state.name.trim() ? { name: state.name.trim() } : {}), + }, + }); + const created = result.createResourceChartImage; + if (!created || created.__typename !== 'TypeResourceChartImage') { + throw new Error('Could not create the chart image'); + } + router.replace(`${base}/image/${created.id}/edit/${destination}`); + return; + } + + const result = await GraphQL(chartWizardCreateDoc, headers, { + chartInput: vizChartInput(state), + }); + const created = result.createResourceChart; + if (!created || created.__typename !== 'TypeResourceChart') { + throw new Error('Could not create the chart'); + } + router.replace(`${base}/${created.id}/edit/${destination}`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (message !== 'Chart configuration is incomplete') { + toast(`Could not create the chart. ${message}`); + } + throw error; + } finally { + creating.current = false; + setSaving(false); + setStatus('success'); + } + }; + + return ( + setState((current) => ({ ...current, name: title }))} + goBackURL={base} + status={status} + currentStep={1} + buildComplete={isBuilderValid(state, hasImage)} + setStepShowErrors={setShowErrors} + onStepClick={(step) => { + if (step !== 2) return; + void createChart('publish').catch(() => undefined); + }} + > + setState((current) => ({ ...current, ...patch }))} + datasets={catalog.datasets} + datasetsLoading={catalog.datasetsLoading} + fields={catalog.fields} + fieldsLoading={catalog.fieldsLoading} + hasImage={hasImage} + imageName={imageFile?.name} + remoteImageUrl={localPreview} + previewOptions={null} + showErrors={showErrors} + saving={saving} + onImageFile={handleImageFile} + onSave={() => { + void createChart('build').catch(() => undefined); + }} + /> + + ); +} diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/charts/wizard/BuildStep.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/charts/wizard/BuildStep.tsx new file mode 100644 index 00000000..84daa4dd --- /dev/null +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/charts/wizard/BuildStep.tsx @@ -0,0 +1,1026 @@ +'use client'; + +import { useState } from 'react'; +import { ChartTypes } from '@/gql/generated/graphql'; +import { + IconCalendar, + IconHash, + IconPhoto, + IconTypography, + IconX, +} from '@tabler/icons-react'; +import ReactECharts from 'echarts-for-react'; +import { + Button, + DropZone, + Icon, + SectionCard, + Select, + Spinner, + Switch, + Tag, + Text, + TextField, +} from 'opub-ui'; + +import { cn } from '@/lib/utils'; +import { Icons } from '@/components/icons'; +import { + EntityRowPicker, + type EntityRow, +} from '../../usecases/edit/components/EntityRowPicker'; +import { CHART_FIELD_DRAG_TYPE, FieldList } from './FieldList'; +import { + AGGREGATE_OPTIONS, + aggregateLabel, + allowsMultipleValues, + builderErrors, + columnKind, + fieldById, + hasPreviewOptions, + isAxisSelection, + MAP_SUBTYPE_OPTIONS, + recommendedSelection, + SERIES_COLORS, + type BuilderState, + type ChartSelection, + type SchemaField, +} from './model'; + +export interface DatasetOption { + id: string; + title: string; + geographies: string[]; + resources: { id: string; name: string }[]; +} + +const CHART_CARDS: { + value: ChartSelection; + label: string; + description: string; + icon: keyof typeof Icons; +}[] = [ + { + value: 'BAR', + label: 'Bar', + description: 'Compare values across categories.', + icon: 'chartBar', + }, + { + value: 'LINE', + label: 'Line', + description: 'Show change or trends over time.', + icon: 'chartLine', + }, + { + value: 'TREEMAP', + label: 'Treemap', + description: 'Show parts of a whole as sized blocks.', + icon: 'chartTreeMap', + }, + { + value: 'MAP', + label: 'Map', + description: 'Show values by geography.', + icon: 'chartMap', + }, + { + value: 'BIG_NUMBER', + label: 'Big Number', + description: 'Highlight one key value.', + icon: 'chartBigNumber', + }, + { + value: 'IMAGE', + label: 'Upload Image', + description: 'Add an externally created visualisation.', + icon: 'photo', + }, +]; + +function FieldError({ message }: { message?: string }) { + if (!message) return null; + return ( + + {message} + + ); +} + +function SelectedField({ + name, + kind = 'numeric', + onRemove, +}: { + name: string; + kind?: 'numeric' | 'date' | 'text'; + onRemove: () => void; +}) { + const Icon = + kind === 'date' + ? IconCalendar + : kind === 'text' + ? IconTypography + : IconHash; + return ( +
+ + + + {name} + +
+ ); +} + +function DropZoneBox({ + active, + onDropId, + children, +}: { + active: boolean; + onDropId: (id: string) => void; + children: React.ReactNode; +}) { + const [over, setOver] = useState(false); + return ( +
{ + if (!active) return; + event.preventDefault(); + event.dataTransfer.dropEffect = 'copy'; + if (!over) setOver(true); + }} + onDragLeave={(event) => { + if (!event.currentTarget.contains(event.relatedTarget as Node | null)) { + setOver(false); + } + }} + onDrop={(event) => { + event.preventDefault(); + setOver(false); + const id = event.dataTransfer.getData(CHART_FIELD_DRAG_TYPE); + if (active && id) onDropId(id); + }} + className={cn( + 'mt-2 flex flex-col gap-1.5 rounded-3 border-1 border-dashed border-borderSubdued p-1.5', + active && 'border-borderHighlight bg-surfaceHighlightSubdued', + active && over && 'bg-surfaceSelected' + )} + > + {children} +
+ ); +} + +function datasetRow(item: DatasetOption): EntityRow { + const places = item.geographies.slice(0, 3).join(' · '); + const files = `${item.resources.length} ${item.resources.length === 1 ? 'file' : 'files'}`; + return { + id: item.id, + title: item.title, + subtitle: [places, files].filter(Boolean).join(' · '), + }; +} + +function SelectedDataset({ + dataset, + onChange, +}: { + dataset: DatasetOption; + onChange: () => void; +}) { + const row = datasetRow(dataset); + return ( +
+
+ + + +
+ + {row.title} + + {row.subtitle ? ( + + {row.subtitle} + + ) : null} +
+
+ +
+ ); +} + +function valueChipLabel(fieldName: string, aggregateType: string) { + if (!aggregateType || aggregateType === 'NONE') return fieldName; + return `${aggregateLabel(aggregateType)} of ${fieldName}`; +} + +export function BuildStep({ + state, + onChange, + datasets, + datasetsLoading, + fields, + fieldsLoading, + imageName, + hasImage, + previewOptions, + remoteImageUrl, + showErrors, + saving, + onSave, + onImageFile, + selections, +}: { + state: BuilderState; + onChange: (patch: Partial) => void; + datasets: DatasetOption[]; + datasetsLoading: boolean; + fields: SchemaField[]; + fieldsLoading: boolean; + imageName?: string | null; + hasImage: boolean; + previewOptions?: Record | null; + remoteImageUrl?: string | null; + showErrors: boolean; + saving: boolean; + onSave: () => void; + onImageFile: (file: File | null) => void; + selections?: ChartSelection[]; +}) { + const [dragging, setDragging] = useState(null); + const [replacingDataset, setReplacingDataset] = useState(false); + const errors = showErrors ? builderErrors(state, hasImage) : {}; + const dataset = datasets.find((item) => item.id === state.datasetId); + const recommended = state.resourceId ? recommendedSelection(fields) : null; + const numericFields = fields.filter( + (field) => columnKind(field.format) === 'numeric' + ); + const singleValue = !allowsMultipleValues(state.selection); + const valuesFull = + state.selection === 'BIG_NUMBER' || state.selection === 'TREEMAP' + ? state.yAxisColumns.length >= 1 + : false; + + const patch = (next: Partial) => onChange(next); + + const selectChart = (selection: ChartSelection) => { + const next: Partial = { selection }; + if (selection !== 'BAR' && selection !== 'LINE') { + next.yAxisColumns = state.yAxisColumns.slice(0, 1); + } + if (selection === 'IMAGE') { + next.resourceId = ''; + } + patch(next); + }; + + const addValue = (id: string) => { + const field = fieldById(fields, id); + if (!field || columnKind(field.format) !== 'numeric') return; + if (state.yAxisColumns.some((column) => column.fieldName === id)) return; + if (valuesFull) return; + const next = [ + ...(singleValue ? [] : state.yAxisColumns), + { + fieldName: id, + label: field.fieldName, + color: SERIES_COLORS[state.yAxisColumns.length % SERIES_COLORS.length], + }, + ]; + patch({ + yAxisColumns: singleValue ? next.slice(-1) : next, + showLegend: !singleValue && next.length > 1 ? state.showLegend : false, + }); + }; + + const setCategory = (id: string) => { + if (!fieldById(fields, id)) return; + patch({ xAxisColumn: id }); + }; + + const roleFor = (id: string): string | null => { + if (state.selection === 'MAP') { + if ( + id === state.regionColumn || + id === state.polygonField || + id === state.geospatialField + ) { + return 'Geography'; + } + if (id === state.latField) return 'Latitude'; + if (id === state.lngField) return 'Longitude'; + if (id === state.valueColumn) return 'Value'; + if (id === state.nameField) return 'Name'; + return null; + } + if (state.selection !== 'BIG_NUMBER' && id === state.xAxisColumn) + return 'X-axis'; + if (state.yAxisColumns.some((column) => column.fieldName === id)) { + return state.selection === 'BIG_NUMBER' ? 'Value' : 'Y-axis'; + } + return null; + }; + + const categoryLabel = + state.selection === 'MAP' ? 'Geographic field' : 'X-axis'; + const valueLabel = + state.selection === 'BAR' || state.selection === 'LINE' + ? 'Values (Y-axis)' + : 'Value'; + + const showConfigure = + state.selection && state.selection !== 'IMAGE' && Boolean(state.resourceId); + const showImage = state.selection === 'IMAGE' && Boolean(state.datasetId); + const showPreview = + Boolean(state.selection) && + (state.selection === 'IMAGE' || state.resourceId); + + return ( +
+ +
+
+ + Dataset * + + {datasetsLoading ? ( + + ) : dataset && !replacingDataset ? ( + setReplacingDataset(true)} + /> + ) : ( +
+
+ item.id !== state.datasetId) + .map(datasetRow)} + selected={[]} + onAdd={(item) => { + setReplacingDataset(false); + if (item.id === state.datasetId) return; + patch({ + datasetId: item.id, + resourceId: '', + xAxisColumn: '', + yAxisColumns: [], + regionColumn: '', + valueColumn: '', + latField: '', + lngField: '', + polygonField: '', + geospatialField: '', + nameField: '', + }); + }} + onRemove={() => undefined} + /> +
+ {replacingDataset && dataset ? ( + + ) : null} +
+ )} + +
+ + {dataset && state.selection !== 'IMAGE' ? ( + dataset.resources.length === 0 ? ( + + No files are available in this dataset. + + ) : ( + ({ + label: field.fieldName, + value: field.id, + }))} + placeholder="+ Add a field" + onChange={setCategory} + /> + )} + + +
+ ) : null} + +
+ + {valueLabel} * + + + {allowsMultipleValues(state.selection) + ? 'The numbers to measure. Add more than one to compare them.' + : 'The number to show.'} + + + {state.yAxisColumns.map((column) => { + const field = fieldById(fields, column.fieldName); + return ( + + patch({ + yAxisColumns: state.yAxisColumns.filter( + (item) => item.fieldName !== column.fieldName + ), + }) + } + /> + ); + })} + {!valuesFull ? ( + patch({ aggregateType: value })} + /> + + {isAxisSelection(state.selection) && + state.selection !== 'TREEMAP' ? ( + <> + patch({ xAxisLabel: value })} + /> + patch({ yAxisLabel: value })} + /> + {state.yAxisColumns.length > 1 ? ( + + patch({ showLegend: checked }) + } + /> + ) : null} + + ) : null} + + {state.selection === 'BAR' ? ( + <> + + patch({ stacked: checked }) + } + /> + onChange({ mapSubtype: value as ChartTypes })} + /> + + {assam ? ( + 0 ? textFields : fields} + dragging={dragging} + onSelect={(id) => assign(id, 'regionColumn')} + onClear={() => onChange({ regionColumn: '' })} + error={errors.category} + /> + ) : null} + + {state.mapSubtype === ChartTypes.PointMap ? ( + <> + assign(id, 'latField')} + onClear={() => onChange({ latField: '' })} + error={errors.category} + /> + assign(id, 'lngField')} + onClear={() => onChange({ lngField: '' })} + /> + + ) : null} + + {state.mapSubtype === ChartTypes.PolygonMap ? ( + assign(id, 'polygonField')} + onClear={() => onChange({ polygonField: '' })} + error={errors.category} + /> + ) : null} + + {state.mapSubtype === ChartTypes.GeospatialMap ? ( + assign(id, 'geospatialField')} + onClear={() => onChange({ geospatialField: '' })} + error={errors.category} + /> + ) : null} + + assign(id, 'valueColumn')} + onClear={() => onChange({ valueColumn: '' })} + error={errors.value} + /> + + {!assam ? ( + 0 ? textFields : fields} + dragging={dragging} + onSelect={(id) => assign(id, 'nameField')} + onClear={() => onChange({ nameField: '' })} + /> + ) : null} + + ({ + label: field.fieldName, + value: field.id, + }))} + placeholder="+ Add a field" + onChange={onSelect} + /> + )} + + +
+ ); +} diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/charts/wizard/ChartEditFrame.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/charts/wizard/ChartEditFrame.tsx new file mode 100644 index 00000000..ffd0b53f --- /dev/null +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/charts/wizard/ChartEditFrame.tsx @@ -0,0 +1,98 @@ +'use client'; + +import { ReactNode } from 'react'; +import { useParams, usePathname, useRouter } from 'next/navigation'; +import { useQuery } from '@tanstack/react-query'; + +import { GraphQL } from '@/lib/api'; +import { ChartWizardShell } from './ChartWizardShell'; +import { useChartEditStatus } from './context'; +import { chartImageDetailsDoc, chartWizardDetailsDoc } from './documents'; +import { chartsBasePath, entityHeaders } from './model'; + +export function ChartEditFrame({ + kind, + children, +}: { + kind: 'viz' | 'image'; + children: ReactNode; +}) { + const params = useParams<{ + entityType: string; + entitySlug: string; + chartID: string; + imageID: string; + }>(); + const pathname = usePathname(); + const router = useRouter(); + const { + status, + runBeforeNavigateHandler, + buildComplete, + titleOverride, + saveTitle, + setStepShowErrors, + } = useChartEditStatus(); + + const id = kind === 'viz' ? params.chartID : params.imageID; + const headers = entityHeaders(params.entityType, params.entitySlug); + const currentStep = pathname?.includes('/publish') ? 2 : 1; + const editBase = `${chartsBasePath(params.entityType, params.entitySlug)}${ + kind === 'viz' ? `/${id}/edit` : `/image/${id}/edit` + }`; + + const vizQuery = useQuery( + [`chart-wizard-title-${id}`], + () => + GraphQL(chartWizardDetailsDoc, headers, { + chartDetailsId: id, + }), + { enabled: kind === 'viz' && Boolean(id) } + ); + + const imageQuery = useQuery( + [`chart-image-title-${id}`], + () => + GraphQL(chartImageDetailsDoc, headers, { + imageId: id, + }), + { enabled: kind === 'image' && Boolean(id) } + ); + + const loadedTitle = + kind === 'viz' + ? vizQuery.data?.resourceChart?.name + : imageQuery.data?.resourceChartImage?.name; + const titlePending = + kind === 'viz' ? !vizQuery.isFetched : !imageQuery.isFetched; + + const handleStepClick = (step: number) => { + const nextPath = step === 2 ? 'publish' : 'build'; + const destination = `${editBase}/${nextPath}`; + if (pathname?.includes(`/${nextPath}`)) return; + void runBeforeNavigateHandler() + .then((shouldContinue) => { + if (shouldContinue === false) return; + router.push(destination); + }) + .catch(() => { + setStepShowErrors(true); + }); + }; + + return ( + + {children} + + ); +} diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/charts/wizard/ChartWizardShell.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/charts/wizard/ChartWizardShell.tsx new file mode 100644 index 00000000..5e73c1ef --- /dev/null +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/charts/wizard/ChartWizardShell.tsx @@ -0,0 +1,81 @@ +'use client'; + +import { ReactNode } from 'react'; +import { IconChartBar, IconClipboardCheck } from '@tabler/icons-react'; +import type { StepperItem } from 'opub-ui'; +import { Icon, Text } from 'opub-ui'; + +import { WizardFrame } from '@/app/[locale]/dashboard/[entityType]/[entitySlug]/provider-flow/WizardFrame'; +import { wizardStepContent } from '@/app/[locale]/dashboard/[entityType]/[entitySlug]/provider-flow/step-content'; +import { Icons } from '@/components/icons'; +import type { ProviderSaveStatus } from '@/app/[locale]/dashboard/[entityType]/[entitySlug]/provider-flow/edit-status'; + +import styles from './wizard.module.scss'; + +export function ChartWizardShell({ + title, + titlePending, + onTitleSave, + goBackURL, + status, + currentStep, + buildComplete, + onStepClick, + setStepShowErrors, + children, +}: { + title: string; + titlePending?: boolean; + onTitleSave?: (title: string) => void; + goBackURL: string; + status: ProviderSaveStatus; + currentStep: number; + buildComplete: boolean; + onStepClick: (step: number) => void; + setStepShowErrors: (show: boolean) => void; + children: ReactNode; +}) { + const steps: StepperItem[] = [ + { + step: 1, + label: 'Build', + description: 'Choose data and build your chart', + icon: IconChartBar, + isCompleted: buildComplete, + content: wizardStepContent(currentStep === 1, children, setStepShowErrors), + }, + { + step: 2, + label: 'Review & Publish', + description: 'Check readiness', + icon: IconClipboardCheck, + isCompleted: false, + content: wizardStepContent(currentStep === 2, children, setStepShowErrors), + }, + ]; + + return ( + + + + Public visibility · Public once published + +
+ } + /> + ); +} diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/charts/wizard/FieldList.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/charts/wizard/FieldList.tsx new file mode 100644 index 00000000..ca263dfb --- /dev/null +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/charts/wizard/FieldList.tsx @@ -0,0 +1,132 @@ +'use client'; + +import { useState } from 'react'; +import { + IconCalendar, + IconGripVertical, + IconHash, + IconTypography, +} from '@tabler/icons-react'; +import { Text } from 'opub-ui'; + +import { cn } from '@/lib/utils'; +import { + columnKind, + columnKindLabel, + type ColumnKind, + type SchemaField, +} from './model'; + +export const CHART_FIELD_DRAG_TYPE = 'application/x-chart-field'; + +const KIND_ICON = { + numeric: IconHash, + date: IconCalendar, + text: IconTypography, +} as const; + +export function FieldList({ + fields, + roleFor, + onDragField, +}: { + fields: SchemaField[]; + roleFor: (id: string) => string | null; + onDragField: (field: SchemaField | null) => void; +}) { + const [query, setQuery] = useState(''); + const normalized = query.trim().toLowerCase(); + const matched = normalized + ? fields.filter((field) => + field.fieldName.toLowerCase().includes(normalized) + ) + : fields; + const visible = [...matched].sort((left, right) => { + const leftSelected = roleFor(left.id) ? 0 : 1; + const rightSelected = roleFor(right.id) ? 0 : 1; + return leftSelected - rightSelected; + }); + + return ( +
+
+
+
+ Fields + + {normalized + ? `${visible.length} of ${fields.length}` + : fields.length} + +
+ setQuery(event.target.value)} + placeholder="Search fields…" + aria-label="Search fields" + className="text-bodyMd w-full rounded-2 border-1 border-solid border-borderSubdued bg-surfaceDefault px-3 py-2 outline-none" + /> + + Drag a field into a box on the right, or pick it from the list. + +
+
    + {visible.length === 0 ? ( +
  • + No fields match your search. +
  • + ) : ( + visible.map((field) => { + const kind: ColumnKind = columnKind(field.format); + const Icon = KIND_ICON[kind]; + const role = roleFor(field.id); + return ( +
  • { + event.dataTransfer.setData(CHART_FIELD_DRAG_TYPE, field.id); + event.dataTransfer.effectAllowed = 'copy'; + onDragField(field); + }} + onDragEnd={() => onDragField(null)} + className={cn( + 'hover:bg-surfaceNeutral group flex cursor-grab items-center gap-3 rounded-2 px-2.5 py-2 active:cursor-grabbing', + role && 'bg-surfaceSelected' + )} + > + +
    + + {field.fieldName} + + + {columnKindLabel(kind)} + +
    + {role ? ( + + {role} + + ) : null} + +
  • + ); + }) + )} +
+
+
+ ); +} diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/charts/wizard/ReviewStep.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/charts/wizard/ReviewStep.tsx new file mode 100644 index 00000000..e528d346 --- /dev/null +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/charts/wizard/ReviewStep.tsx @@ -0,0 +1,178 @@ +'use client'; + +import { ReactNode } from 'react'; +import { useRouter } from 'next/navigation'; +import { + IconAlertTriangle, + IconCircleCheck, + IconPencil, + IconSend, +} from '@tabler/icons-react'; +import { Button, SectionCard, Text, TextField } from 'opub-ui'; + +export interface ReviewRow { + label: string; + value: string; +} + +export interface ReadinessItem { + label: string; + ok: boolean; +} + +export function ReviewStep({ + datasetTitle, + resourceName, + chartTypeLabel, + configRows, + chartName, + onChartName, + preview, + checks, + editBase, + saving, + publishing, + onSave, + onPublish, + publishLabel = 'Publish Changes', +}: { + datasetTitle: string; + resourceName?: string | null; + chartTypeLabel: string; + configRows: ReviewRow[]; + chartName: string; + onChartName: (name: string) => void; + preview: ReactNode; + checks: ReadinessItem[]; + editBase: string; + saving: boolean; + publishing: boolean; + onSave: () => void; + onPublish: () => void; + publishLabel?: string; +}) { + const router = useRouter(); + const ready = checks.every((check) => check.ok); + + const editAction = (label: string, hash: string) => [ + { + icon: IconPencil, + content: label, + onAction: () => router.push(`${editBase}/build${hash}`), + }, + ]; + + return ( +
+
+ + Review & Publish + +
+ + Check your information before making this content available publicly. + +
+
+ + +
+ + {resourceName ? : null} +
+
+ + +
+ + {configRows.map((row) => ( + + ))} +
+
+ + + + + + {preview} + +
+
+ + {ready ? 'Ready to publish' : 'Needs attention'} + +
+ {checks.map((check) => ( +
+ {check.ok ? ( + + ) : ( + + )} + {check.label} +
+ ))} +
+ +
+ + Publishing will replace the current public version of this chart immediately. + +
+ +
+
+ +
+ + +
+
+ ); +} + +function ReviewField({ label, value }: { label: string; value: string }) { + return ( +
+ + {label.toUpperCase()} + + + {value || '—'} + +
+ ); +} diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/charts/wizard/catalog.ts b/app/[locale]/dashboard/[entityType]/[entitySlug]/charts/wizard/catalog.ts new file mode 100644 index 00000000..99ff5a35 --- /dev/null +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/charts/wizard/catalog.ts @@ -0,0 +1,66 @@ +'use client'; + +import { useQuery } from '@tanstack/react-query'; + +import { GraphQL } from '@/lib/api'; +import { chartWizardDatasetsDoc, chartWizardSchemaDoc } from './documents'; +import { entityHeaders, type SchemaField } from './model'; +import type { DatasetOption } from './BuildStep'; + +export function useChartCatalog( + entityType: string, + entitySlug: string, + datasetId: string, + resourceId: string +) { + const headers = entityHeaders(entityType, entitySlug); + + const datasetsQuery = useQuery([`chart-wizard-datasets-${entitySlug}`], () => + GraphQL(chartWizardDatasetsDoc, headers) + ); + + const schemaQuery = useQuery( + [`chart-wizard-schema-${datasetId}`], + () => + GraphQL(chartWizardSchemaDoc, headers, { + datasetId, + }), + { enabled: Boolean(datasetId) } + ); + + const datasets: DatasetOption[] = + datasetsQuery.data?.datasets?.map((dataset) => ({ + id: dataset.id, + title: dataset.title, + geographies: dataset.geographies?.map((geography) => geography.name) ?? [], + resources: + dataset.resources?.map((resource) => ({ + id: resource.id, + name: resource.name, + })) ?? [], + })) ?? []; + + const schema = schemaQuery.data?.datasetResources?.find( + (resource) => resource.id === resourceId + )?.schema; + + const fields: SchemaField[] = + schema?.map((field) => ({ + id: field.id, + fieldName: field.fieldName, + format: field.format ?? '', + })) ?? []; + + return { + datasets, + datasetsLoading: datasetsQuery.isLoading, + fields, + fieldsLoading: Boolean(datasetId) && schemaQuery.isLoading, + }; +} + +export function imageSource(url?: string | null): string | null { + if (!url) return null; + if (url.startsWith('http://') || url.startsWith('https://')) return url; + return `${process.env.NEXT_PUBLIC_BACKEND_URL ?? ''}${url}`; +} diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/charts/wizard/context.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/charts/wizard/context.tsx new file mode 100644 index 00000000..b9b2c583 --- /dev/null +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/charts/wizard/context.tsx @@ -0,0 +1,57 @@ +'use client'; + +import { createContext, useContext, useRef, useState } from 'react'; + +import { useProviderEditState } from '@/app/[locale]/dashboard/[entityType]/[entitySlug]/provider-flow/edit-status'; + +type TitleSaveHandler = (title: string) => void; + +type ChartEditStatusContextValue = ReturnType & { + buildComplete: boolean; + setBuildComplete: (complete: boolean) => void; + titleOverride: string | null; + setTitleOverride: (title: string | null) => void; + registerTitleSave: (handler: TitleSaveHandler) => void; + saveTitle: TitleSaveHandler; +}; + +const ChartEditStatusContext = createContext( + null +); + +export function ChartEditStatusProvider({ + children, +}: { + children: React.ReactNode; +}) { + const editState = useProviderEditState(); + const [buildComplete, setBuildComplete] = useState(false); + const [titleOverride, setTitleOverride] = useState(null); + const titleSaveRef = useRef(() => {}); + + return ( + { + titleSaveRef.current = handler; + }, + saveTitle: (title) => titleSaveRef.current(title), + }} + > + {children} + + ); +} + +export function useChartEditStatus() { + const context = useContext(ChartEditStatusContext); + if (!context) { + throw new Error('useChartEditStatus must be used within ChartEditStatusProvider'); + } + return context; +} diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/charts/wizard/documents.ts b/app/[locale]/dashboard/[entityType]/[entitySlug]/charts/wizard/documents.ts new file mode 100644 index 00000000..c003a6c9 --- /dev/null +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/charts/wizard/documents.ts @@ -0,0 +1,190 @@ +import { graphql } from '@/gql'; + +export const chartWizardDatasetsDoc = graphql(` + query ChartWizardDatasets { + datasets { + id + title + geographies { + name + } + resources { + id + name + } + } + } +`); + +export const chartWizardSchemaDoc = graphql(` + query ChartWizardSchema($datasetId: UUID!) { + datasetResources(datasetId: $datasetId) { + id + name + schema { + fieldName + id + format + } + } + } +`); + +export const chartWizardDetailsDoc = graphql(` + query ChartWizardDetails($chartDetailsId: UUID!) { + resourceChart(chartDetailsId: $chartDetailsId) { + id + name + chartType + status + chart { + options + } + dataset { + id + title + } + resource { + id + name + schema { + fieldName + id + format + } + } + chartOptions { + aggregateType + orientation + stacked + showLegend + xAxisLabel + yAxisLabel + label + valuePrefix + valueSuffix + xAxisColumn { + id + fieldName + } + yAxisColumn { + color + label + field { + id + fieldName + } + } + regionColumn { + id + fieldName + } + valueColumn { + id + fieldName + } + latField { + id + fieldName + } + lngField { + id + fieldName + } + polygonField { + id + fieldName + } + geospatialField { + id + fieldName + } + nameField { + id + fieldName + } + } + } + } +`); + +export const chartWizardCreateDoc = graphql(` + mutation ChartWizardCreate($chartInput: ResourceChartInput!) { + createResourceChart(chartInput: $chartInput) { + __typename + ... on TypeResourceChart { + id + name + } + } + } +`); + +export const chartWizardSaveDoc = graphql(` + mutation ChartWizardSave($chartInput: ResourceChartInput!) { + editResourceChart(chartInput: $chartInput) { + __typename + ... on TypeResourceChart { + id + name + chart { + options + } + } + } + } +`); + +export const chartWizardPublishDoc = graphql(` + mutation ChartWizardPublish($chartId: UUID!) { + publishResourceChart(chartId: $chartId) + } +`); + +export const chartImageDetailsDoc = graphql(` + query ChartImageWizardDetails($imageId: UUID!) { + resourceChartImage(imageId: $imageId) { + id + name + status + dataset { + id + title + } + image { + name + url + } + } + } +`); + +export const chartImageCreateDoc = graphql(` + mutation ChartImageWizardCreate($input: ResourceChartImageInput!) { + createResourceChartImage(input: $input) { + __typename + ... on TypeResourceChartImage { + id + name + } + } + } +`); + +export const chartImageUpdateDoc = graphql(` + mutation ChartImageWizardUpdate($input: ResourceChartImageInputPartial!) { + updateResourceChartImage(input: $input) { + __typename + ... on TypeResourceChartImage { + id + name + } + } + } +`); + +export const chartImagePublishDoc = graphql(` + mutation ChartImageWizardPublish($resourceChartImageId: UUID!) { + publishResourceChartImage(resourceChartImageId: $resourceChartImageId) + } +`); diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/charts/wizard/model.ts b/app/[locale]/dashboard/[entityType]/[entitySlug]/charts/wizard/model.ts new file mode 100644 index 00000000..a4f01e19 --- /dev/null +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/charts/wizard/model.ts @@ -0,0 +1,458 @@ +import { + ChartTypes, + type ChartOptions, + type ResourceChartInput, +} from '@/gql/generated/graphql'; + +export type ChartSelection = + | 'BAR' + | 'LINE' + | 'TREEMAP' + | 'BIG_NUMBER' + | 'MAP' + | 'IMAGE'; + +export type ColumnKind = 'numeric' | 'date' | 'text'; + +export interface SchemaField { + id: string; + fieldName: string; + format: string; +} + +export interface YAxisItem { + fieldName: string; + label: string; + color: string; +} + +export interface BuilderState { + name: string; + datasetId: string; + resourceId: string; + selection: ChartSelection | null; + mapSubtype: ChartTypes; + aggregateType: string; + xAxisColumn: string; + yAxisColumns: YAxisItem[]; + xAxisLabel: string; + yAxisLabel: string; + showLegend: boolean; + stacked: boolean; + orientation: string; + valuePrefix: string; + displayLabel: string; + regionColumn: string; + valueColumn: string; + latField: string; + lngField: string; + polygonField: string; + geospatialField: string; + nameField: string; +} + +export interface BuilderErrors { + dataset?: string; + file?: string; + chartType?: string; + category?: string; + value?: string; + image?: string; +} + +export const SERIES_COLORS = [ + '#2563eb', + '#16a34a', + '#d97706', + '#dc2626', + '#7c3aed', + '#0891b2', +]; + +export const MAP_SUBTYPE_OPTIONS: { label: string; value: ChartTypes }[] = [ + { label: 'Assam districts', value: ChartTypes.AssamDistrict }, + { label: 'Assam revenue circles', value: ChartTypes.AssamRc }, + { label: 'Point map', value: ChartTypes.PointMap }, + { label: 'Polygon map', value: ChartTypes.PolygonMap }, + { label: 'Geospatial map', value: ChartTypes.GeospatialMap }, +]; + +const MAP_TYPES = new Set(MAP_SUBTYPE_OPTIONS.map((option) => option.value)); + +export const AGGREGATE_OPTIONS = [ + { label: 'None', value: 'NONE' }, + { label: 'Sum', value: 'SUM' }, + { label: 'Average', value: 'AVG' }, + { label: 'Count', value: 'COUNT' }, +]; + +export function emptyBuilder(): BuilderState { + return { + name: '', + datasetId: '', + resourceId: '', + selection: null, + mapSubtype: ChartTypes.AssamDistrict, + aggregateType: 'SUM', + xAxisColumn: '', + yAxisColumns: [], + xAxisLabel: '', + yAxisLabel: '', + showLegend: false, + stacked: false, + orientation: 'vertical', + valuePrefix: '', + displayLabel: '', + regionColumn: '', + valueColumn: '', + latField: '', + lngField: '', + polygonField: '', + geospatialField: '', + nameField: '', + }; +} + +export function columnKind(format: string): ColumnKind { + const value = format.toUpperCase(); + if (['INTEGER', 'NUMBER', 'FLOAT', 'DECIMAL', 'DOUBLE'].includes(value)) { + return 'numeric'; + } + if (['DATE', 'DATETIME', 'TIME', 'TIMESTAMP'].includes(value)) { + return 'date'; + } + return 'text'; +} + +export function columnKindLabel(kind: ColumnKind): string { + if (kind === 'numeric') return 'Number'; + if (kind === 'date') return 'Date'; + return 'Text'; +} + +export function isMapType(type: ChartTypes | null | undefined): boolean { + return Boolean(type && MAP_TYPES.has(type)); +} + +export function selectionFromType(type: ChartTypes | null | undefined): ChartSelection { + if (!type || isMapType(type)) return type && isMapType(type) ? 'MAP' : 'BAR'; + if (type === ChartTypes.Line) return 'LINE'; + if (type === ChartTypes.Treemap) return 'TREEMAP'; + if (type === ChartTypes.BigNumber) return 'BIG_NUMBER'; + return 'BAR'; +} + +export function apiChartType(state: BuilderState): ChartTypes | null { + if (state.selection === 'BAR') return ChartTypes.Bar; + if (state.selection === 'LINE') return ChartTypes.Line; + if (state.selection === 'TREEMAP') return ChartTypes.Treemap; + if (state.selection === 'BIG_NUMBER') return ChartTypes.BigNumber; + if (state.selection === 'MAP') return state.mapSubtype; + return null; +} + +export function chartTypeLabel(state: Pick): string { + if (state.selection === 'BAR') return 'Bar'; + if (state.selection === 'LINE') return 'Line'; + if (state.selection === 'TREEMAP') return 'Treemap'; + if (state.selection === 'BIG_NUMBER') return 'Big Number'; + if (state.selection === 'IMAGE') return 'Upload Image'; + if (state.selection === 'MAP') { + return ( + MAP_SUBTYPE_OPTIONS.find((option) => option.value === state.mapSubtype)?.label ?? + 'Map' + ); + } + return '—'; +} + +export function isAxisSelection(selection: ChartSelection | null): boolean { + return selection === 'BAR' || selection === 'LINE' || selection === 'TREEMAP'; +} + +export function allowsMultipleValues(selection: ChartSelection | null): boolean { + return selection === 'BAR' || selection === 'LINE'; +} + +export function recommendedSelection(fields: SchemaField[]): ChartSelection { + return fields.some((field) => columnKind(field.format) === 'date') ? 'LINE' : 'BAR'; +} + +export function fieldById(fields: SchemaField[], id: string): SchemaField | undefined { + return fields.find((field) => field.id === id); +} + +export function normalizeAggregate(value: string | null | undefined): string { + if (!value) return 'SUM'; + if (value.toUpperCase() === 'AVERAGE') return 'AVG'; + const known = AGGREGATE_OPTIONS.some((option) => option.value === value.toUpperCase()); + return known ? value.toUpperCase() : 'SUM'; +} + +export function normalizeOrientation(value: string | null | undefined): string { + if (!value) return 'vertical'; + const normalized = value.toLowerCase(); + if (normalized === 'horizontal') return 'horizontal'; + return 'vertical'; +} + +interface ChartOptionsSource { + aggregateType?: string | null; + orientation?: string | null; + stacked?: boolean | null; + showLegend?: boolean | null; + xAxisLabel?: string | null; + yAxisLabel?: string | null; + xAxisColumn?: { id?: string | null } | null; + yAxisColumn?: Array<{ + field?: { id?: string | null } | null; + label?: string | null; + color?: string | null; + }> | null; + regionColumn?: { id?: string | null } | null; + valueColumn?: { id?: string | null } | null; + label?: string | null; + valuePrefix?: string | null; + valueSuffix?: string | null; + latField?: { id?: string | null } | null; + lngField?: { id?: string | null } | null; + polygonField?: { id?: string | null } | null; + geospatialField?: { id?: string | null } | null; + nameField?: { id?: string | null } | null; +} + +export interface ChartRecordSource { + name?: string | null; + chartType?: ChartTypes | null; + dataset?: { id?: string | null } | null; + resource?: { id?: string | null } | null; + chartOptions?: ChartOptionsSource | null; +} + +export function builderFromChart(chart: ChartRecordSource): BuilderState { + const selection = selectionFromType(chart.chartType); + const options = chart.chartOptions; + return { + ...emptyBuilder(), + name: chart.name ?? '', + datasetId: chart.dataset?.id ?? '', + resourceId: chart.resource?.id ?? '', + selection, + mapSubtype: chart.chartType && isMapType(chart.chartType) + ? chart.chartType + : ChartTypes.AssamDistrict, + aggregateType: normalizeAggregate(options?.aggregateType), + xAxisColumn: options?.xAxisColumn?.id ?? '', + yAxisColumns: + options?.yAxisColumn?.flatMap((column) => { + const fieldName = column.field?.id; + if (!fieldName) return []; + return [ + { + fieldName, + label: column.label ?? '', + color: column.color || SERIES_COLORS[0], + }, + ]; + }) ?? [], + xAxisLabel: options?.xAxisLabel ?? '', + yAxisLabel: options?.yAxisLabel ?? '', + showLegend: options?.showLegend ?? false, + stacked: options?.stacked ?? false, + orientation: normalizeOrientation(options?.orientation), + valuePrefix: options?.valuePrefix || options?.valueSuffix || '', + displayLabel: options?.label ?? '', + regionColumn: options?.regionColumn?.id ?? '', + valueColumn: options?.valueColumn?.id ?? '', + latField: options?.latField?.id ?? '', + lngField: options?.lngField?.id ?? '', + polygonField: options?.polygonField?.id ?? '', + geospatialField: options?.geospatialField?.id ?? '', + nameField: options?.nameField?.id ?? '', + }; +} + +export function buildChartOptions(state: BuilderState): ChartOptions { + const options: ChartOptions = { + aggregateType: state.aggregateType || 'SUM', + showLegend: state.showLegend, + orientation: state.orientation || 'vertical', + stacked: state.stacked, + xAxisLabel: state.xAxisLabel || 'X-Axis', + yAxisLabel: state.yAxisLabel || 'Y-Axis', + }; + + if (state.selection === 'BIG_NUMBER') { + if (state.yAxisColumns.length > 0) { + options.yAxisColumn = state.yAxisColumns.slice(0, 1); + } + if (state.displayLabel) options.label = state.displayLabel; + if (state.valuePrefix) options.valuePrefix = state.valuePrefix; + return options; + } + + if (state.selection === 'MAP') { + if (state.valueColumn) options.valueColumn = state.valueColumn; + if (state.nameField) options.nameField = state.nameField; + if ( + state.mapSubtype === ChartTypes.AssamDistrict || + state.mapSubtype === ChartTypes.AssamRc + ) { + if (state.regionColumn) options.regionColumn = state.regionColumn; + } + if (state.mapSubtype === ChartTypes.PointMap) { + if (state.latField) options.latField = state.latField; + if (state.lngField) options.lngField = state.lngField; + } + if (state.mapSubtype === ChartTypes.PolygonMap && state.polygonField) { + options.polygonField = state.polygonField; + } + if (state.mapSubtype === ChartTypes.GeospatialMap && state.geospatialField) { + options.geospatialField = state.geospatialField; + } + return options; + } + + if (state.xAxisColumn) options.xAxisColumn = state.xAxisColumn; + if (state.yAxisColumns.length > 0) { + options.yAxisColumn = allowsMultipleValues(state.selection) + ? state.yAxisColumns + : state.yAxisColumns.slice(0, 1); + } + return options; +} + +export function builderErrors( + state: BuilderState, + hasImage: boolean +): BuilderErrors { + const errors: BuilderErrors = {}; + if (!state.datasetId) errors.dataset = 'Select a dataset.'; + if (!state.selection) errors.chartType = 'Select a chart type.'; + + if (state.selection === 'IMAGE') { + if (!hasImage) errors.image = 'Upload a chart image.'; + return errors; + } + + if (!state.resourceId) errors.file = 'Select a file or resource.'; + if (!state.selection) return errors; + + if (isAxisSelection(state.selection)) { + if (!state.xAxisColumn) errors.category = 'Choose a field for the category axis.'; + if (state.yAxisColumns.length === 0) { + errors.value = 'Choose at least one numeric value.'; + } + } + + if (state.selection === 'BIG_NUMBER' && state.yAxisColumns.length === 0) { + errors.value = 'Choose the number to highlight.'; + } + + if (state.selection === 'MAP') { + if ( + (state.mapSubtype === ChartTypes.AssamDistrict || + state.mapSubtype === ChartTypes.AssamRc) && + !state.regionColumn + ) { + errors.category = 'Choose the geographic field.'; + } + if (state.mapSubtype === ChartTypes.PointMap && (!state.latField || !state.lngField)) { + errors.category = 'Choose latitude and longitude fields.'; + } + if (state.mapSubtype === ChartTypes.PolygonMap && !state.polygonField) { + errors.category = 'Choose the polygon field.'; + } + if (state.mapSubtype === ChartTypes.GeospatialMap && !state.geospatialField) { + errors.category = 'Choose the geospatial field.'; + } + if (!state.valueColumn) errors.value = 'Choose the value that colours the map.'; + } + + return errors; +} + +export function isBuilderValid(state: BuilderState, hasImage: boolean): boolean { + return Object.keys(builderErrors(state, hasImage)).length === 0; +} + +export function hasPreviewOptions(options: unknown): boolean { + return Boolean( + options && + typeof options === 'object' && + !Array.isArray(options) && + Object.keys(options as Record).length > 0 + ); +} + +export function aggregateLabel(value: string): string { + return AGGREGATE_OPTIONS.find((option) => option.value === value)?.label ?? value; +} + +export function vizChartInput( + state: BuilderState, + chartId?: string +): ResourceChartInput { + const type = apiChartType(state); + return { + ...(chartId ? { chartId } : {}), + resource: state.resourceId, + ...(state.name.trim() ? { name: state.name.trim() } : {}), + ...(type ? { type } : {}), + options: buildChartOptions(state), + }; +} + +export function configRows( + state: BuilderState, + labelFor: (id: string) => string +): { label: string; value: string }[] { + const named = (id: string) => (id ? labelFor(id) || id : '—'); + if (state.selection === 'BIG_NUMBER') { + return [ + { + label: 'Value', + value: state.yAxisColumns.map((column) => named(column.fieldName)).join(', ') || '—', + }, + { label: 'Aggregation', value: aggregateLabel(state.aggregateType) }, + { label: 'Unit', value: state.valuePrefix || '—' }, + { label: 'Display label', value: state.displayLabel || '—' }, + ]; + } + if (state.selection === 'MAP') { + const geography = + state.mapSubtype === ChartTypes.PointMap + ? `${named(state.latField)}, ${named(state.lngField)}` + : state.mapSubtype === ChartTypes.PolygonMap + ? named(state.polygonField) + : state.mapSubtype === ChartTypes.GeospatialMap + ? named(state.geospatialField) + : named(state.regionColumn); + return [ + { label: 'Geography', value: geography }, + { label: 'Value', value: named(state.valueColumn) }, + { label: 'Aggregation', value: aggregateLabel(state.aggregateType) }, + ]; + } + if (!state.selection || state.selection === 'IMAGE') return []; + return [ + { label: 'X-axis', value: named(state.xAxisColumn) }, + { + label: 'Values (Y-axis)', + value: state.yAxisColumns.map((column) => named(column.fieldName)).join(', ') || '—', + }, + { label: 'Aggregation', value: aggregateLabel(state.aggregateType) }, + ]; +} + +export function chartsBasePath(entityType: string, entitySlug: string): string { + return `/dashboard/${entityType}/${entitySlug}/charts`; +} + +export function entityHeaders( + entityType: string, + entitySlug: string +): Record { + if (entityType === 'self') return {}; + return { [entityType]: entitySlug }; +} diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/charts/wizard/wizard.module.scss b/app/[locale]/dashboard/[entityType]/[entitySlug]/charts/wizard/wizard.module.scss new file mode 100644 index 00000000..cfe837d8 --- /dev/null +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/charts/wizard/wizard.module.scss @@ -0,0 +1,18 @@ +.chartStepper { + padding-left: var(--space-6); + padding-right: var(--space-6); + padding-top: var(--space-6); + --action-primary-basic-default: var(--blue-primary-color); + --action-primary-basic-hovered: var(--blue-primary-text); + --action-primary-basic-pressed: var(--blue-primary-text); + --text-highlight: var(--blue-primary-color); + + [class*='buttonNext'] { + background-color: var(--blue-primary-color) !important; + border-color: var(--blue-primary-color) !important; + color: var(--base-pure-white) !important; + --button-color: var(--blue-primary-color); + --button-color-hover: var(--blue-primary-text); + --button-color-active: var(--blue-primary-text); + } +} diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/collaboratives/listing.ts b/app/[locale]/dashboard/[entityType]/[entitySlug]/collaboratives/listing.ts new file mode 100644 index 00000000..140f6984 --- /dev/null +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/collaboratives/listing.ts @@ -0,0 +1,101 @@ +import { graphql } from '@/gql'; +import { CollaborativeStatus } from '@/gql/generated/graphql'; +import type { SortField } from 'opub-ui'; + +import { + formatCount, + formatUpdated, + joinNames, +} from '../provider-flow/table-listing'; + +export const COLLABORATIVE_TAB_KEYS: readonly string[] = [ + CollaborativeStatus.Draft, + CollaborativeStatus.Published, +]; + +export const COLLABORATIVE_TABLE_STATUSES = [ + { key: CollaborativeStatus.Draft, label: 'Draft' }, + { key: CollaborativeStatus.Published, label: 'Published' }, +]; + +export const INITIAL_COLLABORATIVE_SORT: SortField[] = [ + { field: 'modified', direction: 'desc' }, +]; + +export const COLLABORATIVE_FIELD_MAP: Record = { + geography: 'geographies__id', + sector: 'sectors__id', +}; + +export const collaborativesTableQueryDoc = graphql(` + query CollaborativesTableListing( + $filters: [FilterSpec!] + $sortOptions: [SortSpec!] + $limit: Int + $offset: Int + ) { + collaborativesTable( + filters: $filters + sortOptions: $sortOptions + limit: $limit + offset: $offset + ) { + totalItemsCount + statusCounts { + status + count + } + data { + id + title + status + modified + useCaseCount + sectors { + name + } + geographies { + name + } + } + } + } +`); + +export type CollaborativeListingRow = { + id: string; + title: string; + sector: string; + geography: string; + useCases: string; + status: string; + modified: string; +}; + +type CollaborativeTableRecord = { + id: string; + title?: string | null; + status: string; + modified: string; + useCaseCount: number; + sectors?: Array<{ name?: string | null }> | null; + geographies?: Array<{ name?: string | null }> | null; +}; + +export function toCollaborativeRows( + collaboratives: CollaborativeTableRecord[] +): CollaborativeListingRow[] { + return collaboratives.map((collaborative) => ({ + id: collaborative.id, + title: collaborative.title?.trim() || 'Untitled Collaborative', + sector: joinNames(collaborative.sectors), + geography: joinNames(collaborative.geographies), + useCases: formatCount( + collaborative.useCaseCount ?? 0, + 'use case', + 'use cases' + ), + status: collaborative.status, + modified: formatUpdated(collaborative.modified), + })); +} diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/collaboratives/page.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/collaboratives/page.tsx index 79c01bdf..c4c65433 100644 --- a/app/[locale]/dashboard/[entityType]/[entitySlug]/collaboratives/page.tsx +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/collaboratives/page.tsx @@ -1,49 +1,41 @@ 'use client'; +import { useParams, useRouter } from 'next/navigation'; import { graphql } from '@/gql'; -import { CollaborativeStatus, Ordering } from '@/gql/generated/graphql'; +import { CollaborativeStatus } from '@/gql/generated/graphql'; import { useMutation, useQuery } from '@tanstack/react-query'; -import { useParams, useRouter } from 'next/navigation'; -import { parseAsString, useQueryState } from 'nuqs'; -import { Button, DataTable, Icon, IconButton, Text, toast } from 'opub-ui'; -import { useEffect } from 'react'; -import { twMerge } from 'tailwind-merge'; +import { toast } from 'opub-ui'; -import { Icons } from '@/components/icons'; -import { LinkButton } from '@/components/Link'; -import { Loading } from '@/components/loading'; import { GraphQL } from '@/lib/api'; -import { formatDate } from '@/lib/utils'; -import { ActionBar } from '../provider-flow/ActionBar'; -import { Navigation } from '../provider-flow/Navigation'; - -interface CollaborativeListItem { - id: string; - title?: string | null; - created?: string | null; - modified?: string | null; -} - -interface CollaborativeTableRow { - id: string; - title?: string | null; - created: string; - modified: string; -} - -const allCollaboratives = graphql(` - query CollaborativesData( - $filters: CollaborativeFilter - $order: CollaborativeOrder - ) { - collaboratives(filters: $filters, order: $order) { - title - id - created - modified - } - } -`); +import { Icons } from '@/components/icons'; +import { + listingRowActions, + ListingStatus, + ListingTitle, + ProviderListingTable, +} from '../provider-flow/ProviderListingTable'; +import { + errorMessage, + listingEmptyLabel, + listingOwner, + listingQueryOptions, + listingQuerySettled, + sectorGeographyFilters, + toApiFilters, + toApiSort, + useListingFacets, + useProviderTable, + useSettledStatusCounts, +} from '../provider-flow/table-listing'; +import { + COLLABORATIVE_FIELD_MAP, + COLLABORATIVE_TAB_KEYS, + COLLABORATIVE_TABLE_STATUSES, + CollaborativeListingRow, + collaborativesTableQueryDoc, + INITIAL_COLLABORATIVE_SORT, + toCollaborativeRows, +} from './listing'; const deleteCollaborative = graphql(` mutation deleteCollaborative($collaborativeId: String!) { @@ -51,7 +43,7 @@ const deleteCollaborative = graphql(` } `); -const AddCollaborative = graphql(` +const addCollaborative = graphql(` mutation AddCollaborative { addCollaborative { __typename @@ -63,7 +55,7 @@ const AddCollaborative = graphql(` } `); -const unPublishCollaborative = graphql(` +const unpublishCollaborative = graphql(` mutation unPublishCollaborativeMutation($collaborativeId: String!) { unpublishCollaborative(collaborativeId: $collaborativeId) { __typename @@ -76,256 +68,206 @@ const unPublishCollaborative = graphql(` } `); +const collaborativeColumns = [ + { + accessorKey: 'title', + header: 'Collaborative', + searchable: true, + meta: { truncate: true }, + cell: ({ row }: { row: { original: CollaborativeListingRow } }) => ( + + ), + }, + { + accessorKey: 'sector', + header: 'Sector', + maxSize: 160, + meta: { optional: true, truncate: true }, + }, + { + accessorKey: 'geography', + header: 'Geography', + maxSize: 160, + meta: { optional: true, truncate: true }, + }, + { + accessorKey: 'useCases', + header: 'Use Cases', + meta: { optional: true }, + }, + { + accessorKey: 'status', + header: 'Status', + meta: { optional: true }, + cell: ({ row }: { row: { original: CollaborativeListingRow } }) => ( + + ), + }, + { + accessorKey: 'modified', + header: 'Last Updated', + }, +]; + export default function CollaborativePage() { const router = useRouter(); const params = useParams<{ entityType?: string; entitySlug?: string }>(); const entityType = params?.entityType; const entitySlug = params?.entitySlug; + const ownerArgs = listingOwner(entityType, entitySlug); + const isValidParams = ownerArgs !== null; - const isValidParams = - typeof entityType === 'string' && typeof entitySlug === 'string'; - - const ownerArgs: Record | null = isValidParams - ? { [entityType]: entitySlug } - : null; - - const [navigationTab, setNavigationTab] = useQueryState( - 'tab', - parseAsString.withDefault('drafts') + const table = useProviderTable( + COLLABORATIVE_TAB_KEYS, + INITIAL_COLLABORATIVE_SORT ); + const facets = useListingFacets(entityType, entitySlug); - const AllCollaboratives = - useQuery( - [ - 'fetch_Collaboratives', - entityType, - entitySlug, - navigationTab ?? 'drafts', - ], - () => - GraphQL(allCollaboratives, ownerArgs || {}, { - filters: { - status: navigationTab === 'published' ? CollaborativeStatus.Published : CollaborativeStatus.Draft, - }, - order: { modified: Ordering.Desc }, - }), - { enabled: isValidParams } - ); - - useEffect(() => { - if (isValidParams) { - AllCollaboratives.refetch(); + const collaborativesQuery = useQuery( + [ + 'collaboratives_table', + entityType, + entitySlug, + table.filters, + table.sorting, + table.pagination, + ], + () => + GraphQL(collaborativesTableQueryDoc, ownerArgs || {}, { + filters: toApiFilters(table.filters, COLLABORATIVE_FIELD_MAP), + sortOptions: toApiSort(table.sorting, COLLABORATIVE_FIELD_MAP), + limit: table.pagination.limit, + offset: table.pagination.offset, + }), + { + enabled: isValidParams, + ...listingQueryOptions, } - }, [navigationTab, isValidParams, AllCollaboratives]); + ); - const COLLAB_LIST_TOAST_ID = 'collaboratives-list-toast'; + const statusCounts = useSettledStatusCounts( + collaborativesQuery.data?.collaborativesTable.statusCounts, + listingQuerySettled(collaborativesQuery), + COLLABORATIVE_TAB_KEYS + ); - const DeleteCollaborativeMutation = useMutation( - [`delete_Collaborative`], - (data: { id: string }) => - GraphQL(deleteCollaborative, ownerArgs || {}, { - collaborativeId: data.id, - }), + const deleteMutation = useMutation( + (id: string) => + GraphQL(deleteCollaborative, ownerArgs || {}, { collaborativeId: id }), { - onSuccess: () => { - toast(`Deleted Collaborative successfully`, { id: COLLAB_LIST_TOAST_ID }); - if (isValidParams) { - AllCollaboratives.refetch(); + onSuccess: (result) => { + if (!result.deleteCollaborative) { + toast('Error: Could not delete the collaborative'); + return; } + toast('Deleted collaborative successfully'); + collaborativesQuery.refetch(); }, onError: (err: unknown) => { - toast('Error: ' + (typeof err === 'object' && err !== null && 'message' in err && typeof err.message === 'string' ? err.message : String(err)).split(':')[0], { id: COLLAB_LIST_TOAST_ID }); + toast(`Error: ${errorMessage(err)}`); }, } ); - const CreateCollaborative = useMutation( - [`create_Collaborative`], - () => GraphQL(AddCollaborative, ownerArgs || {}), + const unpublishMutation = useMutation( + (id: string) => + GraphQL(unpublishCollaborative, ownerArgs || {}, { + collaborativeId: id, + }), { - onSuccess: (response) => { - toast(`Collaborative created successfully`, { id: COLLAB_LIST_TOAST_ID }); - if (isValidParams && entityType && entitySlug) { - const created = response.addCollaborative; - const createdId = 'id' in created ? created.id : undefined; - router.push( - `/dashboard/${entityType}/${entitySlug}/collaboratives/edit/${createdId}` - ); - AllCollaboratives.refetch(); + onSuccess: (result) => { + if (result.unpublishCollaborative.__typename !== 'TypeCollaborative') { + toast('Error: Could not unpublish the collaborative'); + return; } + toast('Unpublished collaborative successfully'); + collaborativesQuery.refetch(); }, onError: (err: unknown) => { - toast('Error: ' + (typeof err === 'object' && err !== null && 'message' in err && typeof err.message === 'string' ? err.message : String(err)).split(':')[0], { id: COLLAB_LIST_TOAST_ID }); + toast(`Error: ${errorMessage(err)}`); }, } ); - const UnpublishCollaborativeMutation = useMutation( - [`unpublish_collaborative`], - (data: { id: string }) => - GraphQL(unPublishCollaborative, ownerArgs || {}, { - collaborativeId: data.id, - }), + const createMutation = useMutation( + () => GraphQL(addCollaborative, ownerArgs || {}), { - onSuccess: () => { - toast(`Unpublished collaborative successfully`, { id: COLLAB_LIST_TOAST_ID }); - if (isValidParams) { - AllCollaboratives.refetch(); + onSuccess: (response) => { + const created = response.addCollaborative; + const createdId = 'id' in created ? created.id : undefined; + if (!createdId || !entityType || !entitySlug) { + toast('Error: Could not create the collaborative'); + return; } + toast('Collaborative created successfully!'); + router.push( + `/dashboard/${entityType}/${entitySlug}/collaboratives/edit/${createdId}` + ); }, onError: (err: unknown) => { - toast('Error: ' + (typeof err === 'object' && err !== null && 'message' in err && typeof err.message === 'string' ? err.message : String(err)).split(':')[0], { id: COLLAB_LIST_TOAST_ID }); + toast(`Error: ${errorMessage(err)}`); }, } ); - if (!isValidParams) { + const filterConfig = sectorGeographyFilters(facets); + + if (!isValidParams || !entityType || !entitySlug) { return null; } - const navigationOptions = [ - { - label: 'Drafts', - url: `drafts`, - selected: navigationTab === 'drafts', - }, - { - label: 'Published', - url: `published`, - selected: navigationTab === 'published', - }, - ]; - - const collaborativesListColumns = [ - { - accessorKey: 'title', - header: 'Title', - cell: ({ row }: { row: { original: CollaborativeTableRow } }) => - navigationTab === 'published' ? ( - - {row.original.title} - - ) : ( - - - {row.original.title} - - - ), - }, - { accessorKey: 'created', header: 'Date Created' }, - { accessorKey: 'modified', header: 'Date Modified' }, - { - accessorKey: 'delete', - header: 'Delete', - cell: ({ row }: { row: { original: CollaborativeTableRow } }) => - navigationTab === 'published' ? ( - - ) : ( - { - DeleteCollaborativeMutation.mutate({ - id: row.original?.id, - }); - }} - > - Delete - - ), - }, - ]; - - const generateTableData = (list: CollaborativeListItem[]) => { - return list.map((item) => { - return { - title: item.title, - id: item.id, - created: formatDate(item.created ?? null) || '', - modified: formatDate(item.modified ?? null) || '', - }; - }); - }; + const rows = toCollaborativeRows( + collaborativesQuery.data?.collaborativesTable.data ?? [] + ); return ( - <> -
- - - {AllCollaboratives.data?.collaboratives && AllCollaboratives.data.collaboratives.length > 0 ? ( -
- item.selected)?.label || '' - } - primaryAction={{ - content: 'Add New Collaborative', - onAction: () => CreateCollaborative.mutate(), - }} - /> - - -
- ) : AllCollaboratives.isLoading ? ( - - ) : ( - <> -
-
- - {navigationTab === 'drafts' ? ( - <> - - You have not added any collaborative yet. - - - - ) : ( - - No Published Collaboratives yet. - - )} -
-
- - )} -
- + { + if (!createMutation.isLoading) createMutation.mutate(); + }} + placeholder="Search collaboratives..." + columns={collaborativeColumns} + rows={rows} + sortColumns={['title', 'modified']} + filters={filterConfig} + statuses={COLLABORATIVE_TABLE_STATUSES} + emptyLabel={listingEmptyLabel( + table.filters, + table.status, + 'No collaboratives yet' + )} + loading={collaborativesQuery.isFetching} + loadError={collaborativesQuery.isError && !collaborativesQuery.isFetching} + onRetry={() => collaborativesQuery.refetch()} + totalRows={ + collaborativesQuery.data?.collaborativesTable.totalItemsCount ?? 0 + } + statusCounts={statusCounts} + table={table} + getRowActions={(row: CollaborativeListingRow) => { + const published = row.status === CollaborativeStatus.Published; + return listingRowActions({ + published, + title: row.title, + noun: 'collaborative', + onEdit: () => + router.push( + `/dashboard/${entityType}/${entitySlug}/collaboratives/edit/${row.id}` + ), + onUnpublish: () => unpublishMutation.mutate(row.id), + onDelete: () => deleteMutation.mutate(row.id), + }); + }} + /> ); } diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/listing.module.scss b/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/listing.module.scss index b4904f33..303cfad4 100644 --- a/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/listing.module.scss +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/listing.module.scss @@ -1,6 +1,16 @@ -.listing { +@mixin listing-primary { --action-primary-basic-default: var(--blue-primary-color); --action-primary-basic-hovered: var(--blue-primary-text); --action-primary-basic-pressed: var(--blue-primary-text); --action-primary-basic-depressed: var(--blue-primary-text); } + +.listing { + @include listing-primary; +} + +// The filter popover and confirm dialog are portaled outside .listing. +:global([class*='ManagementFilterPopover']), +:global([class*='AlertDialog-module_AlertDialog']) { + @include listing-primary; +} diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/listing.ts b/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/listing.ts index dd117eb4..ac3d5472 100644 --- a/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/listing.ts +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/listing.ts @@ -1,8 +1,17 @@ import { graphql } from '@/gql'; import { DatasetStatus, DatasetType } from '@/gql/generated/graphql'; -import type { FilterField, SortField } from 'opub-ui'; +import type { SortField } from 'opub-ui'; -export const DATASET_TABLE_PAGE_SIZE = 10; +import { + formatCount, + formatUpdated, + joinNames, +} from '../provider-flow/table-listing'; + +export const DATASET_TAB_KEYS: readonly string[] = [ + DatasetStatus.Draft, + DatasetStatus.Published, +]; export const DATASET_TABLE_STATUSES = [ { key: DatasetStatus.Draft, label: 'Draft' }, @@ -13,6 +22,13 @@ export const INITIAL_DATASET_SORT: SortField[] = [ { field: 'modified', direction: 'desc' }, ]; +export const DATASET_FIELD_MAP: Record = { + datasetType: 'dataset_type', + geography: 'geographies__id', + sector: 'sectors__id', + Tags: 'tags__value', +}; + export const datasetsTableQueryDoc = graphql(` query DatasetsTableListing( $filters: [FilterSpec!] @@ -49,24 +65,6 @@ export const datasetsTableQueryDoc = graphql(` } `); -export const datasetTableSectorsQueryDoc = graphql(` - query DatasetTableSectorOptions { - sectors { - id - name - } - } -`); - -export const datasetTableGeographiesQueryDoc = graphql(` - query DatasetTableGeographyOptions { - geographies { - id - name - } - } -`); - export const datasetTableTagsQueryDoc = graphql(` query DatasetTableTagOptions { tags { @@ -76,13 +74,6 @@ export const datasetTableTagsQueryDoc = graphql(` } `); -const API_FILTER_FIELDS: Record = { - datasetType: 'dataset_type', - geography: 'geographies__id', - sector: 'sectors__id', - Tags: 'tags__value', -}; - export const DATASET_TYPE_FILTER_OPTIONS = [ { label: 'Dataset', value: DatasetType.Data }, { label: 'Prompt dataset', value: DatasetType.Prompt }, @@ -112,82 +103,6 @@ type DatasetTableRecord = { geographies: NamedValue[]; }; -export function statusFilter(status: string): FilterField { - if (status === DatasetStatus.Draft || status === DatasetStatus.Published) { - return { field: 'status', condition: 'exact', value: status }; - } - - return { - field: 'status', - condition: 'in', - value: `${DatasetStatus.Draft},${DatasetStatus.Published}`, - }; -} - -export function withStatusFilter( - filters: FilterField[], - status: string -): FilterField[] { - return [ - ...filters.filter((filter) => filter.field !== 'status'), - statusFilter(status), - ]; -} - -export function toApiFilters(filters: FilterField[]) { - return filters - .filter((filter) => filter.value.trim()) - .map((filter) => ({ - field: API_FILTER_FIELDS[filter.field] ?? filter.field, - condition: filter.condition, - value: filter.value, - })); -} - -export function toStatusCounts( - counts: Array<{ status: string; count: number }> | undefined -): Record { - const draft = - counts?.find((item) => item.status === DatasetStatus.Draft)?.count ?? 0; - const published = - counts?.find((item) => item.status === DatasetStatus.Published)?.count ?? 0; - - return { - all: draft + published, - [DatasetStatus.Draft]: draft, - [DatasetStatus.Published]: published, - }; -} - -export function hasListingFilters(filters: FilterField[], status: string) { - if (status !== 'all') return true; - return filters.some( - (filter) => filter.field !== 'status' && filter.value.trim() - ); -} - -function joinNames(values: NamedValue[] | null | undefined) { - const names = (values ?? []) - .map((item) => item.name?.trim()) - .filter((name): name is string => Boolean(name)); - return names.length > 0 ? names.join(', ') : '—'; -} - -export function formatUpdated(value?: string | null) { - if (!value) return '—'; - const date = new Date(value); - if (Number.isNaN(date.getTime())) return '—'; - return date.toLocaleDateString('en-GB', { - day: '2-digit', - month: 'short', - year: 'numeric', - }); -} - -function formatFileCount(count: number) { - return count === 1 ? '1 file' : `${count} files`; -} - export function toListingRows( datasets: DatasetTableRecord[] ): DatasetListingRow[] { @@ -198,7 +113,7 @@ export function toListingRows( dataset.datasetType === DatasetType.Prompt ? 'Prompt dataset' : 'Dataset', sector: joinNames(dataset.sectors), geography: joinNames(dataset.geographies), - files: formatFileCount(dataset.resourceCount ?? 0), + files: formatCount(dataset.resourceCount ?? 0, 'file', 'files'), status: dataset.status, modified: formatUpdated(dataset.modified), })); diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/page.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/page.tsx index 22fa19ba..810a0446 100644 --- a/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/page.tsx +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/page.tsx @@ -8,46 +8,40 @@ import { DatasetType as GqlDatasetType, } from '@/gql/generated/graphql'; import { useTourTrigger } from '@/hooks/use-tour-trigger'; -import { - IconEyeOff, - IconFile, - IconFileText, - IconPencil, - IconTrash, -} from '@tabler/icons-react'; +import { IconFileText } from '@tabler/icons-react'; import { useMutation, useQuery } from '@tanstack/react-query'; -import { - Badge, - DataTable, - Icon, - Text, - toast, - type ColumnFilterConfig, - type DataTableRowAction, - type FilterField, - type SortField, - type TablePaginationState, -} from 'opub-ui'; +import { toast, type ColumnFilterConfig } from 'opub-ui'; import { GraphQL } from '@/lib/api'; +import { + listingRowActions, + ListingStatus, + ListingTitle, + ProviderListingTable, +} from '../provider-flow/ProviderListingTable'; +import { + errorMessage, + listingEmptyLabel, + listingOwner, + listingQueryOptions, + listingQuerySettled, + sectorGeographyFilters, + toApiFilters, + useListingFacets, + useProviderTable, + useSettledStatusCounts, +} from '../provider-flow/table-listing'; import { DatasetType, DatasetTypeModal } from './components/dataset-type-modal'; import { - DATASET_TABLE_PAGE_SIZE, + DATASET_FIELD_MAP, + DATASET_TAB_KEYS, DATASET_TABLE_STATUSES, DATASET_TYPE_FILTER_OPTIONS, DatasetListingRow, datasetsTableQueryDoc, - datasetTableGeographiesQueryDoc, - datasetTableSectorsQueryDoc, - datasetTableTagsQueryDoc, - hasListingFilters, INITIAL_DATASET_SORT, - toApiFilters, toListingRows, - toStatusCounts, - withStatusFilter, } from './listing'; -import styles from './listing.module.scss'; const createDatasetMutationDoc = graphql(` mutation GenerateDatasetName($createInput: CreateDatasetInput) { @@ -86,16 +80,6 @@ const unpublishDatasetMutationDoc = graphql(` } `); -const columnContentTypes = [ - 'text', - 'text', - 'text', - 'text', - 'text', - 'text', - 'text', -] as const; - interface DatasetTableCell { row: { original: DatasetListingRow }; } @@ -107,14 +91,7 @@ const datasetColumns = [ searchable: true, meta: { truncate: true }, cell: ({ row }: DatasetTableCell) => ( - - - - - - {row.original.title} - - + ), }, { @@ -125,11 +102,13 @@ const datasetColumns = [ { accessorKey: 'sector', header: 'Sector', + maxSize: 160, meta: { optional: true, truncate: true }, }, { accessorKey: 'geography', header: 'Geography', + maxSize: 160, meta: { optional: true, truncate: true }, }, { @@ -142,17 +121,14 @@ const datasetColumns = [ header: 'Status', meta: { optional: true }, cell: ({ row }: DatasetTableCell) => ( - - {row.original.status === DatasetStatus.Published - ? 'Published' - : 'Draft'} - + /> ), }, { @@ -161,79 +137,45 @@ const datasetColumns = [ }, ]; -function optionList( - items: Array<{ label: string; value: string }> -): Array<{ label: string; value: string }> { - return [...items].sort((left, right) => - left.label.localeCompare(right.label) - ); -} - -function errorMessage(err: unknown) { - return typeof err === 'object' && - err !== null && - 'message' in err && - typeof err.message === 'string' - ? err.message.split(':')[0] - : 'Unknown error'; -} - export default function DatasetPage() { useTourTrigger(true, 1500); const router = useRouter(); const params = useParams<{ entityType?: string; entitySlug?: string }>(); const entityType = params?.entityType; const entitySlug = params?.entitySlug; - - const isValidParams = - typeof entityType === 'string' && typeof entitySlug === 'string'; - - const ownerArgs: Record | null = isValidParams - ? { [entityType]: entitySlug } - : null; + const ownerArgs = listingOwner(entityType, entitySlug); + const isValidParams = ownerArgs !== null; const [isTypeModalOpen, setIsTypeModalOpen] = useState(false); - const [filters, setFilters] = useState(() => - withStatusFilter([], 'all') - ); - const [sorting, setSorting] = useState(INITIAL_DATASET_SORT); - const [pagination, setPagination] = useState({ - limit: DATASET_TABLE_PAGE_SIZE, - offset: 0, - }); - const [search, setSearch] = useState(''); - const [status, setStatus] = useState('all'); + const table = useProviderTable(DATASET_TAB_KEYS, INITIAL_DATASET_SORT); + const facets = useListingFacets(entityType, entitySlug); const datasetsQuery = useQuery( - ['datasets_table', entityType, entitySlug, filters, sorting, pagination], + [ + 'datasets_table', + entityType, + entitySlug, + table.filters, + table.sorting, + table.pagination, + ], () => GraphQL(datasetsTableQueryDoc, ownerArgs || {}, { - filters: toApiFilters(filters), - sortOptions: sorting, - limit: pagination.limit, - offset: pagination.offset, + filters: toApiFilters(table.filters, DATASET_FIELD_MAP), + sortOptions: table.sorting, + limit: table.pagination.limit, + offset: table.pagination.offset, }), { enabled: isValidParams, - staleTime: 0, - refetchOnMount: 'always', + ...listingQueryOptions, } ); - const sectorsQuery = useQuery( - ['dataset_table_sectors', entityType, entitySlug], - () => GraphQL(datasetTableSectorsQueryDoc, ownerArgs || {}), - { enabled: isValidParams } - ); - const geographiesQuery = useQuery( - ['dataset_table_geographies', entityType, entitySlug], - () => GraphQL(datasetTableGeographiesQueryDoc, ownerArgs || {}), - { enabled: isValidParams } - ); - const tagsQuery = useQuery( - ['dataset_table_tags', entityType, entitySlug], - () => GraphQL(datasetTableTagsQueryDoc, ownerArgs || {}), - { enabled: isValidParams } + const statusCounts = useSettledStatusCounts( + datasetsQuery.data?.datasetsTable.statusCounts, + listingQuerySettled(datasetsQuery), + DATASET_TAB_KEYS ); const deleteDatasetMutation = useMutation( @@ -296,154 +238,61 @@ export default function DatasetPage() { } ); - const selectStatus = (nextStatus: string) => { - setStatus(nextStatus); - setFilters((current) => withStatusFilter(current, nextStatus)); - }; - const filterConfig: ColumnFilterConfig[] = [ { columnId: 'datasetType', type: 'select', options: DATASET_TYPE_FILTER_OPTIONS, }, - { - columnId: 'geography', - type: 'select', - options: optionList( - (geographiesQuery.data?.geographies ?? []).map((geography) => ({ - label: geography.name, - value: geography.id, - })) - ), - }, - { - columnId: 'sector', - type: 'multiSelect', - options: optionList( - (sectorsQuery.data?.sectors ?? []).map((sector) => ({ - label: sector.name, - value: String(sector.id), - })) - ), - }, - // { - // columnId: 'Tags', - // type: 'multiSelect', - // options: optionList( - // (tagsQuery.data?.tags ?? []).map((tag) => ({ - // label: tag.value, - // value: tag.value, - // })) - // ), - // }, + ...sectorGeographyFilters(facets), ]; - const rows = toListingRows(datasetsQuery.data?.datasetsTable.data ?? []); - const emptyLabel = hasListingFilters(filters, status) - ? 'No records match your filters' - : 'No datasets yet'; - if (!isValidParams || !entityType || !entitySlug) { return null; } + const rows = toListingRows(datasetsQuery.data?.datasetsTable.data ?? []); + return ( <> -
- setIsTypeModalOpen(true)} - placeholder="Search datasets..." - sortColumns={['title', 'modified']} - filters={filterConfig} - statuses={DATASET_TABLE_STATUSES} - defaultRowCount={DATASET_TABLE_PAGE_SIZE} - labels={{ empty: emptyLabel }} - loading={datasetsQuery.isFetching} - loadError={datasetsQuery.isError && !datasetsQuery.isFetching} - onRetry={() => datasetsQuery.refetch()} - totalRows={datasetsQuery.data?.datasetsTable.totalItemsCount ?? 0} - statusCounts={toStatusCounts( - datasetsQuery.data?.datasetsTable.statusCounts - )} - searchState={search} - onSearchChange={setSearch} - statusState={status} - onStatusChange={selectStatus} - filterState={filters} - sortingState={sorting} - paginationState={pagination} - onFiltersChange={(next) => { - const exact = next.find( - (filter) => - filter.field === 'status' && filter.condition === 'exact' - ); - const nextStatus = - exact?.value === DatasetStatus.Draft || - exact?.value === DatasetStatus.Published - ? exact.value - : status; - setStatus(nextStatus); - setFilters(withStatusFilter(next, nextStatus)); - }} - onSortingChange={setSorting} - onPaginationChange={setPagination} - getRowActions={(row: DatasetListingRow): DataTableRowAction[] => { - const actions: DataTableRowAction[] = []; - const isPublished = row.status === DatasetStatus.Published; - - if (isPublished) { - actions.push({ - key: 'unpublish', - icon: IconEyeOff, - color: 'critical', - label: `Unpublish`, - onClick: () => - unpublishDatasetMutation.mutate({ datasetId: row.id }), - alert: { - title: 'Unpublish dataset?', - children: `Unpublishing "${row.title}" will move it back to draft.`, - }, - }); - } else { - actions.push({ - key: 'edit', - icon: IconPencil, - label: `Edit ${row.title}`, - onClick: () => - router.push( - `/dashboard/${entityType}/${entitySlug}/dataset/${row.id}/edit` - ), - }); - } - actions.push({ - key: 'delete', - icon: IconTrash, - label: `Delete`, - destructive: true, - onClick: () => - deleteDatasetMutation.mutate({ datasetId: row.id }), - alert: { - title: 'Delete dataset?', - children: `Deleting "${row.title}" will permanently remove it. This action cannot be undone.`, - }, - }); - return actions; - }} - data-tour="my-datasets" - /> -
+ setIsTypeModalOpen(true)} + placeholder="Search datasets..." + columns={datasetColumns} + rows={rows} + sortColumns={['title', 'modified']} + filters={filterConfig} + statuses={DATASET_TABLE_STATUSES} + emptyLabel={listingEmptyLabel( + table.filters, + table.status, + 'No datasets yet' + )} + loading={datasetsQuery.isFetching} + loadError={datasetsQuery.isError && !datasetsQuery.isFetching} + onRetry={() => datasetsQuery.refetch()} + totalRows={datasetsQuery.data?.datasetsTable.totalItemsCount ?? 0} + statusCounts={statusCounts} + table={table} + tourId="my-datasets" + getRowActions={(row: DatasetListingRow) => + listingRowActions({ + published: row.status === DatasetStatus.Published, + title: row.title, + noun: 'dataset', + onEdit: () => + router.push( + `/dashboard/${entityType}/${entitySlug}/dataset/${row.id}/edit` + ), + onUnpublish: () => + unpublishDatasetMutation.mutate({ datasetId: row.id }), + onDelete: () => deleteDatasetMutation.mutate({ datasetId: row.id }), + }) + } + /> ; + +type ProviderListingTableProps = { + title: string; + subtitle: string; + addLabel: string; + onAdd: () => void; + placeholder: string; + columns: DataTableProps['columns']; + rows: DataTableProps['rows']; + sortColumns: string[]; + filters: ColumnFilterConfig[]; + statuses: Array<{ key: string; label: string }>; + emptyLabel: string; + loading: boolean; + loadError: boolean; + onRetry: () => void; + totalRows: number; + statusCounts?: Record; + table: ListingTableState; + tourId?: string; + getRowActions: NonNullable; +}; + +export function ProviderListingTable({ + title, + subtitle, + addLabel, + onAdd, + placeholder, + columns, + rows, + sortColumns, + filters, + statuses, + emptyLabel, + loading, + loadError, + onRetry, + totalRows, + statusCounts, + table, + tourId, + getRowActions, +}: ProviderListingTableProps) { + return ( +
+ 'text' as const)} + columns={columns} + rows={rows} + hideSelection + style={{ borderRadius: '20px' }} + withServer + title={title} + subtitle={subtitle} + addLabel={addLabel} + onAdd={onAdd} + placeholder={placeholder} + sortColumns={sortColumns} + filters={filters} + statuses={statuses} + defaultRowCount={TABLE_PAGE_SIZE} + labels={{ empty: emptyLabel }} + loading={loading} + loadError={loadError} + onRetry={onRetry} + totalRows={totalRows} + statusCounts={statusCounts} + searchState={table.search} + onSearchChange={table.setSearch} + statusState={table.status} + onStatusChange={table.selectStatus} + filterState={table.filters} + sortingState={table.sorting} + paginationState={table.pagination} + onFiltersChange={table.onFiltersChange} + onSortingChange={table.setSorting} + onPaginationChange={table.setPagination} + getRowActions={getRowActions} + {...(tourId ? { 'data-tour': tourId } : {})} + /> +
+ ); +} + +export function ListingTitle({ + icon, + label, +}: { + icon: IconProps['source']; + label: string; +}) { + return ( + + + + + + {label} + + + ); +} + +export function ListingStatus({ + published, + label, +}: { + published: boolean; + label: string; +}) { + return {label}; +} + +export function listingRowActions({ + published, + title, + noun, + onEdit, + onUnpublish, + onDelete, +}: { + published: boolean; + title: string; + noun: string; + onEdit: () => void; + onUnpublish: () => void; + onDelete: () => void; +}): DataTableRowAction[] { + const actions: DataTableRowAction[] = []; + + if (published) { + actions.push({ + key: 'unpublish', + icon: IconEyeOff, + color: 'critical', + label: 'Unpublish', + onClick: onUnpublish, + alert: { + title: `Unpublish ${noun}?`, + children: `Unpublishing "${title}" will move it back to draft.`, + }, + }); + } else { + actions.push({ + key: 'edit', + icon: IconPencil, + label: `Edit ${title}`, + onClick: onEdit, + }); + } + + actions.push({ + key: 'delete', + icon: IconTrash, + label: 'Delete', + destructive: true, + onClick: onDelete, + alert: { + title: `Delete ${noun}?`, + children: `Deleting "${title}" will permanently remove it. This action cannot be undone.`, + }, + }); + + return actions; +} diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/provider-flow/edit-status.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/provider-flow/edit-status.tsx index 5b906a45..fdff5ce6 100644 --- a/app/[locale]/dashboard/[entityType]/[entitySlug]/provider-flow/edit-status.tsx +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/provider-flow/edit-status.tsx @@ -3,7 +3,7 @@ import { useRef, useState } from 'react'; export type ProviderSaveStatus = 'loading' | 'success'; -export type BeforeNavigateHandler = (() => Promise | void) | null; +export type BeforeNavigateHandler = (() => Promise | void | false) | null; export function useProviderEditState() { const [status, setStatus] = useState('success'); @@ -15,7 +15,7 @@ export function useProviderEditState() { }; const runBeforeNavigateHandler = async () => { - await beforeNavigateHandlerRef.current?.(); + return beforeNavigateHandlerRef.current?.(); }; return { diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/provider-flow/table-listing.ts b/app/[locale]/dashboard/[entityType]/[entitySlug]/provider-flow/table-listing.ts new file mode 100644 index 00000000..f7cd3613 --- /dev/null +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/provider-flow/table-listing.ts @@ -0,0 +1,311 @@ +'use client'; + +import { useState } from 'react'; +import { graphql } from '@/gql'; +import { useQuery } from '@tanstack/react-query'; +import type { + ColumnFilterConfig, + FilterField, + SortField, + TablePaginationState, +} from 'opub-ui'; + +import { GraphQL } from '@/lib/api'; + +export const TABLE_PAGE_SIZE = 10 as const; + +export const EMPTY_CELL = '—'; + +export const listingQueryOptions = { + staleTime: 0, + refetchOnMount: 'always' as const, + keepPreviousData: true, +}; + +export const datasetTableSectorsQueryDoc = graphql(` + query DatasetTableSectorOptions { + sectors { + id + name + } + } +`); + +export const datasetTableGeographiesQueryDoc = graphql(` + query DatasetTableGeographyOptions { + geographies { + id + name + } + } +`); + +export function optionList(items: Array<{ label: string; value: string }>) { + return [...items].sort((left, right) => + left.label.localeCompare(right.label) + ); +} + +export function labelFromValue(value: string, labels?: Record) { + if (labels?.[value]) return labels[value]; + return value + .toLowerCase() + .split('_') + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' '); +} + +export function enumOptions( + values: readonly string[], + labels?: Record +) { + return optionList( + values.map((value) => ({ + value, + label: labelFromValue(value, labels), + })) + ); +} + +export function statusFilter( + status: string, + tabKeys: readonly string[] +): FilterField { + if (tabKeys.includes(status)) { + return { field: 'status', condition: 'exact', value: status }; + } + + return { field: 'status', condition: 'in', value: tabKeys.join(',') }; +} + +export function withStatusFilter( + filters: FilterField[], + status: string, + tabKeys: readonly string[] +): FilterField[] { + return [ + ...filters.filter((filter) => filter.field !== 'status'), + statusFilter(status, tabKeys), + ]; +} + +export function toApiFilters( + filters: FilterField[], + fieldMap: Record +) { + return filters + .filter((filter) => filter.value.trim()) + .map((filter) => ({ + field: fieldMap[filter.field] ?? filter.field, + condition: filter.condition, + value: filter.value, + })); +} + +export function toApiSort( + sorting: SortField[], + fieldMap: Record +) { + return sorting.map((sort) => ({ + field: fieldMap[sort.field] ?? sort.field, + direction: sort.direction, + })); +} + +export function toStatusCounts( + counts: Array<{ status: string; count: number }> | undefined, + tabKeys: readonly string[] +): Record { + const result: Record = { all: 0 }; + for (const key of tabKeys) { + const count = counts?.find((item) => item.status === key)?.count ?? 0; + result[key] = count; + result.all += count; + } + return result; +} + +export function listingQuerySettled(query: { + data?: unknown; + isFetching: boolean; + isPreviousData?: boolean; +}) { + return Boolean(query.data) && !query.isFetching && !query.isPreviousData; +} + +function sameStatusCounts( + left?: Record, + right?: Record +) { + if (!left || !right) return false; + const keys = new Set([...Object.keys(left), ...Object.keys(right)]); + for (const key of keys) { + if (left[key] !== right[key]) return false; + } + return true; +} + +export function useSettledStatusCounts( + counts: Array<{ status: string; count: number }> | undefined, + ready: boolean, + tabKeys: readonly string[] +) { + const settled = ready ? toStatusCounts(counts, tabKeys) : undefined; + const [statusCounts, setStatusCounts] = useState(settled); + if (settled && !sameStatusCounts(statusCounts, settled)) { + setStatusCounts(settled); + } + return statusCounts; +} + +export function hasListingFilters(filters: FilterField[], status: string) { + if (status !== 'all') return true; + return filters.some( + (filter) => filter.field !== 'status' && filter.value.trim() + ); +} + +export function listingEmptyLabel( + filters: FilterField[], + status: string, + empty: string +) { + return hasListingFilters(filters, status) + ? 'No records match your filters' + : empty; +} + +export function sectorGeographyFilters(facets: { + sectors: Array<{ label: string; value: string }>; + geographies: Array<{ label: string; value: string }>; +}): ColumnFilterConfig[] { + return [ + { + columnId: 'geography', + type: 'select', + options: facets.geographies, + }, + { + columnId: 'sector', + type: 'multiSelect', + options: facets.sectors, + }, + ]; +} + +export function useProviderTable( + tabKeys: readonly string[], + initialSort: SortField[] +) { + const [filters, setFilters] = useState(() => + withStatusFilter([], 'all', tabKeys) + ); + const [sorting, setSorting] = useState(initialSort); + const [pagination, setPagination] = useState({ + limit: TABLE_PAGE_SIZE, + offset: 0, + }); + const [search, setSearch] = useState(''); + const [status, setStatus] = useState('all'); + + const selectStatus = (nextStatus: string) => { + setStatus(nextStatus); + setFilters((current) => withStatusFilter(current, nextStatus, tabKeys)); + }; + + const onFiltersChange = (next: FilterField[]) => { + const exact = next.find( + (filter) => filter.field === 'status' && filter.condition === 'exact' + ); + const nextStatus = + exact && tabKeys.includes(exact.value) ? exact.value : status; + setStatus(nextStatus); + setFilters(withStatusFilter(next, nextStatus, tabKeys)); + }; + + return { + filters, + sorting, + setSorting, + pagination, + setPagination, + search, + setSearch, + status, + selectStatus, + onFiltersChange, + }; +} + +export function useListingFacets(entityType?: string, entitySlug?: string) { + const enabled = + typeof entityType === 'string' && typeof entitySlug === 'string'; + const ownerArgs = enabled ? { [entityType]: entitySlug } : {}; + + const sectorsQuery = useQuery( + ['listing_table_sectors', entityType, entitySlug], + () => GraphQL(datasetTableSectorsQueryDoc, ownerArgs), + { enabled } + ); + const geographiesQuery = useQuery( + ['listing_table_geographies', entityType, entitySlug], + () => GraphQL(datasetTableGeographiesQueryDoc, ownerArgs), + { enabled } + ); + + return { + sectors: optionList( + (sectorsQuery.data?.sectors ?? []).map((sector) => ({ + label: sector.name, + value: String(sector.id), + })) + ), + geographies: optionList( + (geographiesQuery.data?.geographies ?? []).map((geography) => ({ + label: geography.name, + value: String(geography.id), + })) + ), + }; +} + +type NamedValue = { name?: string | null }; + +export function joinNames(values: NamedValue[] | null | undefined) { + const names = (values ?? []) + .map((item) => item.name?.trim()) + .filter((name): name is string => Boolean(name)); + return names.length > 0 ? names.join(', ') : EMPTY_CELL; +} + +export function formatUpdated(value?: string | null) { + if (!value) return EMPTY_CELL; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return EMPTY_CELL; + return date.toLocaleDateString('en-GB', { + day: '2-digit', + month: 'short', + year: 'numeric', + }); +} + +export function formatCount(count: number, singular: string, plural: string) { + return count === 1 ? `1 ${singular}` : `${count} ${plural}`; +} + +export function errorMessage(err: unknown) { + return typeof err === 'object' && + err !== null && + 'message' in err && + typeof err.message === 'string' + ? err.message.split(':')[0] + : 'Unknown error'; +} + +export function listingOwner(entityType?: string, entitySlug?: string) { + if (typeof entityType !== 'string' || typeof entitySlug !== 'string') { + return null; + } + return { [entityType]: entitySlug }; +} diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/publications/listing.ts b/app/[locale]/dashboard/[entityType]/[entitySlug]/publications/listing.ts new file mode 100644 index 00000000..58488a4e --- /dev/null +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/publications/listing.ts @@ -0,0 +1,128 @@ +import { graphql } from '@/gql'; +import { PublicationStatus } from '@/gql/generated/graphql'; +import type { SortField } from 'opub-ui'; + +import { + formatUpdated, + joinNames, + labelFromValue, + optionList, +} from '../provider-flow/table-listing'; +import { LICENSE_OPTIONS } from './model'; + +export const PUBLICATION_TAB_KEYS: readonly string[] = [ + PublicationStatus.Draft, + PublicationStatus.Published, +]; + +export const PUBLICATION_TABLE_STATUSES = [ + { key: PublicationStatus.Draft, label: 'Draft' }, + { key: PublicationStatus.Published, label: 'Published' }, +]; + +export const INITIAL_PUBLICATION_SORT: SortField[] = [ + { field: 'modified', direction: 'desc' }, +]; + +export const PUBLICATION_FIELD_MAP: Record = { + resourceType: 'resource_type', + geography: 'geographies__id', + sector: 'sectors__id', +}; + +const LICENSE_LABELS = Object.fromEntries( + LICENSE_OPTIONS.map((option) => [option.value, option.label]) +); + +export const LICENSE_FILTER_OPTIONS = optionList( + LICENSE_OPTIONS.map((option) => ({ + label: option.label, + value: option.value, + })) +); + +export const publicationsTableQueryDoc = graphql(` + query PublicationsTableListing( + $filters: [FilterSpec!] + $sortOptions: [SortSpec!] + $limit: Int + $offset: Int + ) { + publicationsTable( + filters: $filters + sortOptions: $sortOptions + limit: $limit + offset: $offset + ) { + totalItemsCount + statusCounts { + status + count + } + data { + id + title + status + license + modified + resourceType { + id + name + } + sectors { + name + } + geographies { + name + } + } + } + } +`); + +export const publicationResourceTypesQueryDoc = graphql(` + query PublicationTableResourceTypes { + resourceTypes { + id + name + isActive + } + } +`); + +export type PublicationListingRow = { + id: string; + title: string; + resourceType: string; + license: string; + sector: string; + geography: string; + status: string; + modified: string; +}; + +type PublicationTableRecord = { + id: string; + title?: string | null; + status: string; + license: string; + modified: string; + resourceType?: { name?: string | null } | null; + sectors?: Array<{ name?: string | null }> | null; + geographies?: Array<{ name?: string | null }> | null; +}; + +export function toPublicationRows( + publications: PublicationTableRecord[] +): PublicationListingRow[] { + return publications.map((publication) => ({ + id: publication.id, + title: publication.title?.trim() || 'Untitled Publication', + resourceType: publication.resourceType?.name?.trim() || '—', + license: labelFromValue(publication.license, LICENSE_LABELS), + sector: joinNames(publication.sectors), + geography: joinNames(publication.geographies), + status: publication.status, + modified: formatUpdated(publication.modified), + })); +} diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/publications/page.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/publications/page.tsx index 9789ce64..109b18ae 100644 --- a/app/[locale]/dashboard/[entityType]/[entitySlug]/publications/page.tsx +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/publications/page.tsx @@ -1,66 +1,155 @@ 'use client'; -import { useEffect } from 'react'; import { useParams, useRouter } from 'next/navigation'; +import { PublicationStatus } from '@/gql/generated/graphql'; +import { IconFileDescription } from '@tabler/icons-react'; import { useMutation, useQuery } from '@tanstack/react-query'; -import { parseAsString, useQueryState } from 'nuqs'; -import { Button, DataTable, Icon, IconButton, Text, toast } from 'opub-ui'; -import { twMerge } from 'tailwind-merge'; +import { toast, type ColumnFilterConfig } from 'opub-ui'; import { GraphQL } from '@/lib/api'; -import { formatDate } from '@/lib/utils'; -import { Icons } from '@/components/icons'; -import { LinkButton } from '@/components/Link'; -import { Loading } from '@/components/loading'; -import { ActionBar } from '../provider-flow/ActionBar'; -import { Navigation } from '../provider-flow/Navigation'; +import { + listingRowActions, + ListingStatus, + ListingTitle, + ProviderListingTable, +} from '../provider-flow/ProviderListingTable'; +import { + listingEmptyLabel, + listingOwner, + listingQueryOptions, + listingQuerySettled, + optionList, + sectorGeographyFilters, + toApiFilters, + toApiSort, + useListingFacets, + useProviderTable, + useSettledStatusCounts, +} from '../provider-flow/table-listing'; +import { + INITIAL_PUBLICATION_SORT, + LICENSE_FILTER_OPTIONS, + PUBLICATION_FIELD_MAP, + PUBLICATION_TAB_KEYS, + PUBLICATION_TABLE_STATUSES, + PublicationListingRow, + publicationResourceTypesQueryDoc, + publicationsTableQueryDoc, + toPublicationRows, +} from './listing'; import { errorText, mutationMessage } from './model'; import { createPublicationMutation, deletePublicationMutation, - publicationListQuery, unpublishPublicationMutation, } from './queries'; -interface PublicationListItem { - id: string; - title?: string | null; - status?: string | null; - created?: string | null; - modified?: string | null; -} +const publicationColumns = [ + { + accessorKey: 'title', + header: 'Publication', + searchable: true, + meta: { truncate: true }, + cell: ({ row }: { row: { original: PublicationListingRow } }) => ( + + ), + }, + { + accessorKey: 'resourceType', + header: 'Type', + meta: { optional: true, truncate: true }, + }, + { + accessorKey: 'license', + header: 'License', + meta: { optional: true, truncate: true }, + }, + { + accessorKey: 'sector', + header: 'Sector', + maxSize: 160, + meta: { optional: true, truncate: true }, + }, + { + accessorKey: 'geography', + header: 'Geography', + maxSize: 160, + meta: { optional: true, truncate: true }, + }, + { + accessorKey: 'status', + header: 'Status', + meta: { optional: true }, + cell: ({ row }: { row: { original: PublicationListingRow } }) => ( + + ), + }, + { + accessorKey: 'modified', + header: 'Last Updated', + }, +]; export default function PublicationsPage() { const router = useRouter(); const params = useParams<{ entityType?: string; entitySlug?: string }>(); const entityType = params?.entityType; const entitySlug = params?.entitySlug; - const isValidParams = - typeof entityType === 'string' && typeof entitySlug === 'string'; - const ownerArgs = isValidParams ? { [entityType]: entitySlug } : {}; - const [navigationTab, setNavigationTab] = useQueryState('tab', parseAsString); + const ownerArgs = listingOwner(entityType, entitySlug); + const isValidParams = ownerArgs !== null; - const listQuery = useQuery( + const table = useProviderTable( + PUBLICATION_TAB_KEYS, + INITIAL_PUBLICATION_SORT + ); + const facets = useListingFacets(entityType, entitySlug); + + const publicationsQuery = useQuery( [ - 'dashboard_publications', + 'publications_table', entityType, entitySlug, - navigationTab ?? 'drafts', + table.filters, + table.sorting, + table.pagination, ], () => - GraphQL(publicationListQuery, ownerArgs, { - pagination: { limit: 100, offset: 0 }, + GraphQL(publicationsTableQueryDoc, ownerArgs || {}, { + filters: toApiFilters(table.filters, PUBLICATION_FIELD_MAP), + sortOptions: toApiSort(table.sorting, PUBLICATION_FIELD_MAP), + limit: table.pagination.limit, + offset: table.pagination.offset, }), + { + enabled: isValidParams, + ...listingQueryOptions, + } + ); + + const resourceTypesQuery = useQuery( + ['publication_table_resource_types'], + () => GraphQL(publicationResourceTypesQueryDoc, ownerArgs || {}), { enabled: isValidParams } ); - useEffect(() => { - if (navigationTab == null) setNavigationTab('drafts'); - }, [navigationTab, setNavigationTab]); + const statusCounts = useSettledStatusCounts( + publicationsQuery.data?.publicationsTable.statusCounts, + listingQuerySettled(publicationsQuery), + PUBLICATION_TAB_KEYS + ); const deleteMutation = useMutation( (id: string) => - GraphQL(deletePublicationMutation, ownerArgs, { publicationId: id }), + GraphQL(deletePublicationMutation, ownerArgs || {}, { + publicationId: id, + }), { onSuccess: (result) => { const message = mutationMessage( @@ -71,8 +160,8 @@ export default function PublicationsPage() { toast(message); return; } - toast('Publication deleted'); - void listQuery.refetch(); + toast('Deleted publication successfully'); + publicationsQuery.refetch(); }, onError: (error: unknown) => { toast(errorText(error, 'Could not delete the publication.')); @@ -82,7 +171,9 @@ export default function PublicationsPage() { const unpublishMutation = useMutation( (id: string) => - GraphQL(unpublishPublicationMutation, ownerArgs, { publicationId: id }), + GraphQL(unpublishPublicationMutation, ownerArgs || {}, { + publicationId: id, + }), { onSuccess: (result) => { const message = mutationMessage( @@ -93,8 +184,8 @@ export default function PublicationsPage() { toast(message); return; } - toast('Publication unpublished'); - void listQuery.refetch(); + toast('Unpublished publication successfully'); + publicationsQuery.refetch(); }, onError: (error: unknown) => { toast(errorText(error, 'Could not unpublish the publication.')); @@ -103,10 +194,7 @@ export default function PublicationsPage() { ); const createMutation = useMutation( - () => - GraphQL(createPublicationMutation, ownerArgs, { - input: {}, - }), + () => GraphQL(createPublicationMutation, ownerArgs || {}, { input: {} }), { onSuccess: (result) => { const message = mutationMessage( @@ -118,8 +206,7 @@ export default function PublicationsPage() { toast(message || 'Could not create the publication.'); return; } - toast('Publication created successfully'); - void listQuery.refetch(); + toast('Publication created successfully!'); router.push( `/dashboard/${entityType}/${entitySlug}/publications/edit/${id}/files` ); @@ -130,135 +217,74 @@ export default function PublicationsPage() { } ); - if (!isValidParams) return null; - - const navigationOptions = [ - { label: 'Drafts', url: 'drafts', selected: navigationTab !== 'published' }, + const filterConfig: ColumnFilterConfig[] = [ { - label: 'Published', - url: 'published', - selected: navigationTab === 'published', + columnId: 'resourceType', + type: 'select', + options: optionList( + (resourceTypesQuery.data?.resourceTypes ?? []) + .filter((type) => type.isActive) + .map((type) => ({ + label: type.name, + value: String(type.id), + })) + ), }, - ]; - const publishedTab = navigationTab === 'published'; - const rows = (listQuery.data?.publications ?? []).filter((item) => - publishedTab ? item.status === 'PUBLISHED' : item.status !== 'PUBLISHED' - ); - - const columns = [ - { - accessorKey: 'title', - header: 'Title', - cell: ({ row }: { row: { original: PublicationListItem } }) => - publishedTab ? ( - - {row.original.title} - - ) : ( - - - {row.original.title || 'Untitled Publication'} - - - ), - }, - { accessorKey: 'created', header: 'Date Created' }, - { accessorKey: 'modified', header: 'Date Modified' }, { - accessorKey: 'delete', - header: publishedTab ? 'Unpublish' : 'Delete', - cell: ({ row }: { row: { original: PublicationListItem } }) => - publishedTab ? ( - - ) : ( - deleteMutation.mutate(row.original.id)} - > - Delete - - ), + columnId: 'license', + type: 'select', + options: LICENSE_FILTER_OPTIONS, }, + ...sectorGeographyFilters(facets), ]; - const tableRows = rows.map((item) => ({ - title: item.title, - id: item.id, - status: item.status, - created: formatDate(item.created ?? null) || '', - modified: formatDate(item.modified ?? null) || '', - })); + if (!isValidParams || !entityType || !entitySlug) { + return null; + } + + const rows = toPublicationRows( + publicationsQuery.data?.publicationsTable.data ?? [] + ); return ( -
- - {rows.length > 0 ? ( -
- createMutation.mutate(), - }} - /> - -
- ) : listQuery.isLoading ? ( - - ) : ( -
-
- - {publishedTab ? ( - - No published publications yet. - - ) : ( - <> - - You have not added any publication yet. - - - - )} -
-
+ { + if (!createMutation.isLoading) createMutation.mutate(); + }} + placeholder="Search publications..." + columns={publicationColumns} + rows={rows} + sortColumns={['title', 'modified']} + filters={filterConfig} + statuses={PUBLICATION_TABLE_STATUSES} + emptyLabel={listingEmptyLabel( + table.filters, + table.status, + 'No publications yet' )} -
+ loading={publicationsQuery.isFetching} + loadError={publicationsQuery.isError && !publicationsQuery.isFetching} + onRetry={() => publicationsQuery.refetch()} + totalRows={publicationsQuery.data?.publicationsTable.totalItemsCount ?? 0} + statusCounts={statusCounts} + table={table} + getRowActions={(row: PublicationListingRow) => { + const published = row.status === PublicationStatus.Published; + return listingRowActions({ + published, + title: row.title, + noun: 'publication', + onEdit: () => + router.push( + `/dashboard/${entityType}/${entitySlug}/publications/edit/${row.id}/files` + ), + onUnpublish: () => unpublishMutation.mutate(row.id), + onDelete: () => deleteMutation.mutate(row.id), + }); + }} + /> ); } diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/publications/queries.ts b/app/[locale]/dashboard/[entityType]/[entitySlug]/publications/queries.ts index 18deb1b9..8b65be03 100644 --- a/app/[locale]/dashboard/[entityType]/[entitySlug]/publications/queries.ts +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/publications/queries.ts @@ -37,18 +37,6 @@ export type EditorPublication = { blocks?: Array | null; }; -export const publicationListQuery = graphql(` - query dashboardPublications($pagination: OffsetPaginationInput) { - publications(pagination: $pagination) { - id - title - status - created - modified - } - } -`); - export const publicationEditorQuery = operation< { getPublication?: EditorPublication | null }, { publicationId: string } diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/edit/components/EntityRowPicker.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/edit/components/EntityRowPicker.tsx index eb5fea57..f50690c2 100644 --- a/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/edit/components/EntityRowPicker.tsx +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/edit/components/EntityRowPicker.tsx @@ -46,6 +46,7 @@ interface EntityRowPickerProps { onLoadMore?: () => void; onAdd: (item: EntityRow) => void; onRemove: (id: string) => void; + hidePlaceholder?: boolean; roleOptionsFor?: (item: EntityRow) => Array<{ label: string; value: string }>; onRoleChange?: (item: EntityRow, role: string) => void; } @@ -178,6 +179,7 @@ export const EntityRowPicker = forwardRef< onLoadMore, onAdd, onRemove, + hidePlaceholder = false, roleOptionsFor, onRoleChange, }, @@ -433,16 +435,18 @@ export const EntityRowPicker = forwardRef< {menu} {shownSelected.length === 0 ? ( -
- {emptyTitle} - {emptyDescription ? ( -
- - {emptyDescription} - -
- ) : null} -
+ hidePlaceholder ? null : ( +
+ {emptyTitle} + {emptyDescription ? ( +
+ + {emptyDescription} + +
+ ) : null} +
+ ) ) : (
{shownSelected.map((item) => { diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/listing.ts b/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/listing.ts new file mode 100644 index 00000000..4a90f533 --- /dev/null +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/listing.ts @@ -0,0 +1,120 @@ +import { graphql } from '@/gql'; +import { + ApiUseCaseRunningStatusEnum, + UseCaseStatus, +} from '@/gql/generated/graphql'; +import type { SortField } from 'opub-ui'; + +import { + enumOptions, + formatCount, + formatUpdated, + joinNames, + labelFromValue, +} from '../provider-flow/table-listing'; + +export const USE_CASE_TAB_KEYS: readonly string[] = [ + UseCaseStatus.Draft, + UseCaseStatus.Published, +]; + +export const USE_CASE_TABLE_STATUSES = [ + { key: UseCaseStatus.Draft, label: 'Draft' }, + { key: UseCaseStatus.Published, label: 'Published' }, +]; + +export const INITIAL_USE_CASE_SORT: SortField[] = [ + { field: 'modified', direction: 'desc' }, +]; + +export const USE_CASE_FIELD_MAP: Record = { + runningStatus: 'running_status', + geography: 'geographies__id', + sector: 'sectors__id', +}; + +const RUNNING_STATUS_LABELS: Record = { + [ApiUseCaseRunningStatusEnum.Cancelled]: 'Cancelled', + [ApiUseCaseRunningStatusEnum.Completed]: 'Completed', + [ApiUseCaseRunningStatusEnum.Initiated]: 'Initiated', + [ApiUseCaseRunningStatusEnum.OnGoing]: 'Ongoing', + [ApiUseCaseRunningStatusEnum.OnHold]: 'On hold', +}; + +export const RUNNING_STATUS_FILTER_OPTIONS = enumOptions( + Object.values(ApiUseCaseRunningStatusEnum), + RUNNING_STATUS_LABELS +); + +export const useCasesTableQueryDoc = graphql(` + query UseCasesTableListing( + $filters: [FilterSpec!] + $sortOptions: [SortSpec!] + $limit: Int + $offset: Int + ) { + useCasesTable( + filters: $filters + sortOptions: $sortOptions + limit: $limit + offset: $offset + ) { + totalItemsCount + statusCounts { + status + count + } + data { + id + title + status + runningStatus + modified + datasetCount + sectors { + name + } + geographies { + name + } + } + } + } +`); + +export type UseCaseListingRow = { + id: string; + title: string; + runningStatus: string; + sector: string; + geography: string; + datasets: string; + status: string; + modified: string; +}; + +type UseCaseTableRecord = { + id: string; + title?: string | null; + status: string; + runningStatus: string; + modified: string; + datasetCount: number; + sectors?: Array<{ name?: string | null }> | null; + geographies?: Array<{ name?: string | null }> | null; +}; + +export function toUseCaseRows( + useCases: UseCaseTableRecord[] +): UseCaseListingRow[] { + return useCases.map((useCase) => ({ + id: useCase.id, + title: useCase.title?.trim() || 'Untitled Use Case', + runningStatus: labelFromValue(useCase.runningStatus, RUNNING_STATUS_LABELS), + sector: joinNames(useCase.sectors), + geography: joinNames(useCase.geographies), + datasets: formatCount(useCase.datasetCount ?? 0, 'dataset', 'datasets'), + status: useCase.status, + modified: formatUpdated(useCase.modified), + })); +} diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/page.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/page.tsx index 719cc4e6..9064e432 100644 --- a/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/page.tsx +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/page.tsx @@ -1,46 +1,47 @@ 'use client'; +import { useParams, useRouter } from 'next/navigation'; import { graphql } from '@/gql'; -import { Ordering, UseCaseStatus } from '@/gql/generated/graphql'; +import { UseCaseStatus } from '@/gql/generated/graphql'; +import { IconBulb, IconFolderStats } from '@tabler/icons-react'; import { useMutation, useQuery } from '@tanstack/react-query'; -import { useParams, useRouter } from 'next/navigation'; -import { parseAsString, useQueryState } from 'nuqs'; -import { Button, DataTable, Icon, IconButton, Text, toast } from 'opub-ui'; -import { useEffect } from 'react'; -import { twMerge } from 'tailwind-merge'; +import { + FolderKanban, + FolderKanbanIcon, + LucideFolderKanban, +} from 'lucide-react'; +import { toast, type ColumnFilterConfig } from 'opub-ui'; -import { Icons } from '@/components/icons'; -import { LinkButton } from '@/components/Link'; -import { Loading } from '@/components/loading'; import { GraphQL } from '@/lib/api'; -import { formatDate } from '@/lib/utils'; -import { ActionBar } from '../provider-flow/ActionBar'; -import { Navigation } from '../provider-flow/Navigation'; - -interface UseCaseListItem { - id: string; - title?: string | null; - created?: string | null; - modified?: string | null; -} - -interface UseCaseTableRow { - id: string; - title?: string | null; - created: string; - modified: string; -} - -const allUseCases = graphql(` - query UseCasesData($filters: UseCaseFilter, $order: UseCaseOrder) { - useCases(filters: $filters, order: $order) { - title - id - created - modified - } - } -`); +import { + listingRowActions, + ListingStatus, + ListingTitle, + ProviderListingTable, +} from '../provider-flow/ProviderListingTable'; +import { + errorMessage, + listingEmptyLabel, + listingOwner, + listingQueryOptions, + listingQuerySettled, + sectorGeographyFilters, + toApiFilters, + toApiSort, + useListingFacets, + useProviderTable, + useSettledStatusCounts, +} from '../provider-flow/table-listing'; +import { + INITIAL_USE_CASE_SORT, + RUNNING_STATUS_FILTER_OPTIONS, + toUseCaseRows, + USE_CASE_FIELD_MAP, + USE_CASE_TAB_KEYS, + USE_CASE_TABLE_STATUSES, + UseCaseListingRow, + useCasesTableQueryDoc, +} from './listing'; const deleteUseCase = graphql(` mutation deleteUseCase($useCaseId: String!) { @@ -60,7 +61,7 @@ export const AddUseCase = graphql(` } `); -const unPublishUseCase = graphql(` +const unpublishUseCase = graphql(` mutation unPublishUseCaseMutation($useCaseId: String!) { unpublishUseCase(useCaseId: $useCaseId) { __typename @@ -73,256 +74,207 @@ const unPublishUseCase = graphql(` } `); -export default function DatasetPage() { +const useCaseColumns = [ + { + accessorKey: 'title', + header: 'Use Case', + searchable: true, + meta: { truncate: true }, + cell: ({ row }: { row: { original: UseCaseListingRow } }) => ( + + ), + }, + { + accessorKey: 'runningStatus', + header: 'Running Status', + meta: { optional: true }, + }, + { + accessorKey: 'sector', + header: 'Sector', + maxSize: 160, + meta: { optional: true, truncate: true }, + }, + { + accessorKey: 'geography', + header: 'Geography', + meta: { optional: true, truncate: true }, + }, + { + accessorKey: 'datasets', + header: 'Datasets', + meta: { optional: true }, + }, + { + accessorKey: 'status', + header: 'Status', + meta: { optional: true }, + cell: ({ row }: { row: { original: UseCaseListingRow } }) => ( + + ), + }, + { + accessorKey: 'modified', + header: 'Last Updated', + }, +]; + +export default function UseCasesPage() { const router = useRouter(); const params = useParams<{ entityType?: string; entitySlug?: string }>(); const entityType = params?.entityType; const entitySlug = params?.entitySlug; + const ownerArgs = listingOwner(entityType, entitySlug); + const isValidParams = ownerArgs !== null; - const isValidParams = - typeof entityType === 'string' && typeof entitySlug === 'string'; - - const ownerArgs: Record | null = isValidParams - ? { [entityType]: entitySlug } - : null; + const table = useProviderTable(USE_CASE_TAB_KEYS, INITIAL_USE_CASE_SORT); + const facets = useListingFacets(entityType, entitySlug); - const [navigationTab, setNavigationTab] = useQueryState( - 'tab', - parseAsString.withDefault('drafts') - ); - - const AllUseCases = useQuery( - [`fetch_UseCases`, entityType, entitySlug, navigationTab ?? 'drafts'], + const useCasesQuery = useQuery( + [ + 'usecases_table', + entityType, + entitySlug, + table.filters, + table.sorting, + table.pagination, + ], () => - GraphQL( - allUseCases, - ownerArgs || {}, - { - filters: { - status: navigationTab === 'published' ? UseCaseStatus.Published : UseCaseStatus.Draft, - }, - order: { modified: Ordering.Desc }, - } - ), - { enabled: isValidParams } + GraphQL(useCasesTableQueryDoc, ownerArgs || {}, { + filters: toApiFilters(table.filters, USE_CASE_FIELD_MAP), + sortOptions: toApiSort(table.sorting, USE_CASE_FIELD_MAP), + limit: table.pagination.limit, + offset: table.pagination.offset, + }), + { + enabled: isValidParams, + ...listingQueryOptions, + } ); - useEffect(() => { - if (isValidParams) { - AllUseCases.refetch(); - } - }, [navigationTab, isValidParams, AllUseCases]); + const statusCounts = useSettledStatusCounts( + useCasesQuery.data?.useCasesTable.statusCounts, + listingQuerySettled(useCasesQuery), + USE_CASE_TAB_KEYS + ); - const DeleteUseCaseMutation = useMutation( - [`delete_Usecase`], - (data: { id: string }) => - GraphQL( - deleteUseCase, - ownerArgs || {}, - { useCaseId: data.id } - ), + const deleteMutation = useMutation( + (id: string) => GraphQL(deleteUseCase, ownerArgs || {}, { useCaseId: id }), { - onSuccess: () => { - toast(`Deleted UseCase successfully`); - if (isValidParams) { - AllUseCases.refetch(); + onSuccess: (result) => { + if (!result.deleteUseCase) { + toast('Error: Could not delete the use case'); + return; } + toast('Deleted use case successfully'); + useCasesQuery.refetch(); }, onError: (err: unknown) => { - toast('Error: ' + (typeof err === 'object' && err !== null && 'message' in err && typeof err.message === 'string' ? err.message : String(err)).split(':')[0]); + toast(`Error: ${errorMessage(err)}`); }, } ); - const CreateUseCase = useMutation( - [`delete_Usecase`], - () => GraphQL(AddUseCase, ownerArgs || {}), + const unpublishMutation = useMutation( + (id: string) => + GraphQL(unpublishUseCase, ownerArgs || {}, { useCaseId: id }), { - onSuccess: (response) => { - toast(`UseCase created successfully`); - if (isValidParams && entityType && entitySlug) { - const created = response.addUseCase; - const createdId = 'id' in created ? created.id : undefined; - router.push( - `/dashboard/${entityType}/${entitySlug}/usecases/edit/${createdId}/builder` - ); - AllUseCases.refetch(); + onSuccess: (result) => { + if (result.unpublishUseCase.__typename !== 'TypeUseCase') { + toast('Error: Could not unpublish the use case'); + return; } + toast('Unpublished use case successfully'); + useCasesQuery.refetch(); }, onError: (err: unknown) => { - toast('Error: ' + (typeof err === 'object' && err !== null && 'message' in err && typeof err.message === 'string' ? err.message : String(err)).split(':')[0]); + toast(`Error: ${errorMessage(err)}`); }, } ); - const UnpublishDatasetMutation = useMutation( - [`unpublish_usecase`], - (data: { id: string }) => - GraphQL( - unPublishUseCase, - ownerArgs || {}, - { useCaseId: data.id } - ), + + const createMutation = useMutation( + () => GraphQL(AddUseCase, ownerArgs || {}), { - onSuccess: () => { - toast(`Unpublished usecase successfully`); - if (isValidParams) { - AllUseCases.refetch(); + onSuccess: (response) => { + const created = response.addUseCase; + const createdId = 'id' in created ? created.id : undefined; + if (!createdId || !entityType || !entitySlug) { + toast('Error: Could not create the use case'); + return; } + toast('Use case created successfully!'); + router.push( + `/dashboard/${entityType}/${entitySlug}/usecases/edit/${createdId}/builder` + ); }, onError: (err: unknown) => { - toast('Error: ' + (typeof err === 'object' && err !== null && 'message' in err && typeof err.message === 'string' ? err.message : String(err)).split(':')[0]); + toast(`Error: ${errorMessage(err)}`); }, } ); - if (!isValidParams) { - return null; - } - - const navigationOptions = [ - { - label: 'Drafts', - url: `drafts`, - selected: navigationTab === 'drafts', - }, - { - label: 'Published', - url: `published`, - selected: navigationTab === 'published', - }, - ]; - const datasetsListColumns = [ + const filterConfig: ColumnFilterConfig[] = [ { - accessorKey: 'title', - header: 'Title', - cell: ({ row }: { row: { original: UseCaseTableRow } }) => - navigationTab === 'published' ? ( - - {row.original.title} - - ) : ( - - - {row.original.title} - - - ), - }, - { accessorKey: 'created', header: 'Date Created' }, - { accessorKey: 'modified', header: 'Date Modified' }, - { - accessorKey: 'delete', - header: 'Delete', - cell: ({ row }: { row: { original: UseCaseTableRow } }) => - navigationTab === 'published' ? ( - - ) : ( - { - DeleteUseCaseMutation.mutate({ - id: row.original?.id, - }); - }} - > - Delete - - ), + columnId: 'runningStatus', + type: 'select', + options: RUNNING_STATUS_FILTER_OPTIONS, }, + ...sectorGeographyFilters(facets), ]; - const generateTableData = (list: UseCaseListItem[]) => { - return list.map((item) => { - return { - title: item.title, - id: item.id, - created: formatDate(item.created ?? null) || '', - modified: formatDate(item.modified ?? null) || '', - }; - }); - }; - - return ( - <> -
- - - {AllUseCases.data?.useCases && AllUseCases.data.useCases.length > 0 ? ( -
- item.selected)?.label || '' - } - primaryAction={{ - content: 'Add New UseCase', - onAction: () => CreateUseCase.mutate(), - }} - /> + if (!isValidParams || !entityType || !entitySlug) { + return null; + } - -
- ) : AllUseCases.isLoading ? ( - - ) : ( - <> -
-
- - {navigationTab === 'drafts' ? ( - <> - - You have not added any usecase yet. - - - - ) : ( - - No Published UseCases yet. - - )} -
-
- - )} + const rows = toUseCaseRows(useCasesQuery.data?.useCasesTable.data ?? []); - {/* */} -
- + return ( + { + if (!createMutation.isLoading) createMutation.mutate(); + }} + placeholder="Search use cases..." + columns={useCaseColumns} + rows={rows} + sortColumns={['title', 'modified']} + filters={filterConfig} + statuses={USE_CASE_TABLE_STATUSES} + emptyLabel={listingEmptyLabel( + table.filters, + table.status, + 'No use cases yet' + )} + loading={useCasesQuery.isFetching} + loadError={useCasesQuery.isError && !useCasesQuery.isFetching} + onRetry={() => useCasesQuery.refetch()} + totalRows={useCasesQuery.data?.useCasesTable.totalItemsCount ?? 0} + statusCounts={statusCounts} + table={table} + getRowActions={(row: UseCaseListingRow) => { + const published = row.status === UseCaseStatus.Published; + return listingRowActions({ + published, + title: row.title, + noun: 'use case', + onEdit: () => + router.push( + `/dashboard/${entityType}/${entitySlug}/usecases/edit/${row.id}` + ), + onUnpublish: () => unpublishMutation.mutate(row.id), + onDelete: () => deleteMutation.mutate(row.id), + }); + }} + /> ); } diff --git a/components/icons.tsx b/components/icons.tsx index 4dd82d75..fa03cf57 100644 --- a/components/icons.tsx +++ b/components/icons.tsx @@ -22,6 +22,7 @@ import { IconCircleCheck, IconCircleCheckFilled, IconCloudUpload, + IconCpu, IconDatabase, IconDatabaseCog, IconDatabaseEdit, @@ -35,6 +36,7 @@ import { IconFilePlus, IconFileUpload, IconFolder, + IconFolderStats, IconInfoCircle, IconLayoutGrid, IconLayoutList, @@ -137,4 +139,6 @@ export const Icons = { calendarEvent: IconCalendarEvent, fileDownload: IconFileDownload, worldPin: IconWorldPin, + folderStats: IconFolderStats, + iconCpu: IconCpu, };