diff --git a/.github/workflows/auto-build.yml b/.github/workflows/auto-build.yml index 63df11e31..5a6e2fef6 100644 --- a/.github/workflows/auto-build.yml +++ b/.github/workflows/auto-build.yml @@ -20,5 +20,9 @@ jobs: run: yarn install - name: npm package build run: yarn workspace @kanaries/graphic-walker build + - name: Observable Plot renderer plugin build + run: yarn workspace @kanaries/graphic-walker-renderer-observable-plot build + - name: ECharts renderer plugin build + run: yarn workspace @kanaries/graphic-walker-renderer-echarts build - name: Web app (playground) build - run: yarn workspace playground build \ No newline at end of file + run: yarn workspace playground build diff --git a/.github/workflows/bump-version.yml b/.github/workflows/bump-version.yml index 013f88046..015c7e590 100644 --- a/.github/workflows/bump-version.yml +++ b/.github/workflows/bump-version.yml @@ -25,15 +25,27 @@ jobs: working-directory: ./packages/graphic-walker run: | yarn version --no-git-tag-version --new-version ${{ github.event.inputs.newVersion }} + - name: Bump observable renderer plugin version + working-directory: ./packages/renderer-observable-plot + run: | + yarn version --no-git-tag-version --new-version ${{ github.event.inputs.newVersion }} + - name: Bump echarts renderer plugin version + working-directory: ./packages/renderer-echarts + run: | + yarn version --no-git-tag-version --new-version ${{ github.event.inputs.newVersion }} - name: Update playground dependency run: | yarn add @kanaries/graphic-walker@${{ github.event.inputs.newVersion }} + yarn add @kanaries/graphic-walker-renderer-observable-plot@${{ github.event.inputs.newVersion }} + yarn add @kanaries/graphic-walker-renderer-echarts@${{ github.event.inputs.newVersion }} working-directory: ./packages/playground - name: Update Wasm dependecy run: | yarn add -D @kanaries/graphic-walker@${{ github.event.inputs.newVersion }} + yarn add -D @kanaries/graphic-walker-renderer-observable-plot@${{ github.event.inputs.newVersion }} + yarn add -D @kanaries/graphic-walker-renderer-echarts@${{ github.event.inputs.newVersion }} working-directory: ./packages/duckdb-wasm-computation - name: Commit Changes diff --git a/.github/workflows/release-duckdb.yml b/.github/workflows/release-duckdb.yml index 73af56a93..f0089f84d 100644 --- a/.github/workflows/release-duckdb.yml +++ b/.github/workflows/release-duckdb.yml @@ -15,6 +15,10 @@ jobs: run: yarn install - name: npm package build run: yarn workspace @kanaries/graphic-walker build + - name: Observable Plot renderer plugin build + run: yarn workspace @kanaries/graphic-walker-renderer-observable-plot build + - name: ECharts renderer plugin build + run: yarn workspace @kanaries/graphic-walker-renderer-echarts build - name: Duckdb build run: yarn workspace @kanaries/duckdb-computation build - run: npm publish diff --git a/.github/workflows/release-renderer-plugin.yml b/.github/workflows/release-renderer-plugin.yml new file mode 100644 index 000000000..f59168e02 --- /dev/null +++ b/.github/workflows/release-renderer-plugin.yml @@ -0,0 +1,46 @@ +name: Publish Renderer Plugins to npmjs + +on: + workflow_dispatch: + inputs: + npmTag: + description: 'npm dist-tag' + required: true + default: latest + type: choice + options: + - latest + - pre + +jobs: + build-and-publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + # Setup .npmrc file to publish to npm + - uses: actions/setup-node@v3 + with: + node-version: '20.x' + registry-url: 'https://registry.npmjs.org' + + - name: Install deps + run: yarn install + + - name: Observable Plot renderer plugin build + run: yarn workspace @kanaries/graphic-walker-renderer-observable-plot build + + - name: ECharts renderer plugin build + run: yarn workspace @kanaries/graphic-walker-renderer-echarts build + + - name: Publish observable plugin + run: npm publish --tag ${{ github.event.inputs.npmTag }} + working-directory: ./packages/renderer-observable-plot + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Publish echarts plugin + run: npm publish --tag ${{ github.event.inputs.npmTag }} + working-directory: ./packages/renderer-echarts + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ba958838f..2a7bc1137 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,6 +16,10 @@ jobs: run: yarn install - name: npm package build run: yarn workspace @kanaries/graphic-walker build + - name: Observable Plot renderer plugin build + run: yarn workspace @kanaries/graphic-walker-renderer-observable-plot build + - name: ECharts renderer plugin build + run: yarn workspace @kanaries/graphic-walker-renderer-echarts build - name: Web app (playground) build run: yarn workspace playground build - name: Publish package @@ -23,3 +27,13 @@ jobs: working-directory: ./packages/graphic-walker env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + - name: Publish observable plugin + run: npm publish --tag ${{ github.event.release.prerelease && 'pre' || 'latest' }} + working-directory: ./packages/renderer-observable-plot + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + - name: Publish echarts plugin + run: npm publish --tag ${{ github.event.release.prerelease && 'pre' || 'latest' }} + working-directory: ./packages/renderer-echarts + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.gitignore b/.gitignore index b2fd46e4f..19ce931fd 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ app-dist .vercel chartinfo.json stoinfo_v2.json +packages/renderer-echarts/test/__artifacts__ \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..9e26dfeeb --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/README.md b/README.md index d6c6aa50f..3377a7821 100644 --- a/README.md +++ b/README.md @@ -146,6 +146,26 @@ const YourChart: React.FC = props => { export default YourChart; ``` +### Renderer Plugins + +Graphic Walker now supports renderer plugins. + +```typescript +import { + GraphicRenderer, + registerRendererPlugin, +} from '@kanaries/graphic-walker'; +import { createObservablePlotPlugin } from '@kanaries/graphic-walker-renderer-observable-plot'; +import { createEChartsPlugin } from '@kanaries/graphic-walker-renderer-echarts'; + +registerRendererPlugin(createObservablePlotPlugin()); +registerRendererPlugin(createEChartsPlugin()); + +// legacy IDs are still supported: +// - "vega-lite" => builtin:vega +// - "observable-plot" => plugin:observable-plot +``` + The `GraphicRenderer` component accepts same props as `GraphicWalker`, and would display the chart and the filters of the chart to change. ```typescript diff --git a/package.json b/package.json index 3bf2ddadb..02f011517 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,8 @@ "devDependencies": {}, "scripts": { "build": "yarn workspace @kanaries/graphic-walker build", + "build:plugins": "yarn workspace @kanaries/graphic-walker-renderer-observable-plot build && yarn workspace @kanaries/graphic-walker-renderer-echarts build", + "build:all": "yarn build && yarn build:plugins", "deploy": "yarn workspace playground deploy" }, "workspaces": { @@ -35,5 +37,6 @@ "homepage": "https://github.com/Kanaries/graphic-walker#readme", "engines": { "node": ">=20.0.0" - } + }, + "packageManager": "yarn@1.22.19+sha256.732620bac8b1690d507274f025f3c6cfdc3627a84d9642e38a07452cc00e0f2e" } diff --git a/packages/graphic-walker/package.json b/packages/graphic-walker/package.json index bac198084..172095a4d 100644 --- a/packages/graphic-walker/package.json +++ b/packages/graphic-walker/package.json @@ -52,7 +52,6 @@ "@heroicons/react": "^2.2.0", "@kanaries/react-beautiful-dnd": "^0.1.1", "@kanaries/web-data-loader": "^0.1.7", - "@observablehq/plot": "^0.6.16", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-context-menu": "^2.2.16", @@ -98,14 +97,14 @@ "postcss": "^8.3.7", "postinstall-postinstall": "^2.1.0", "rbush": "^3.0.1", - "re-resizable": "^6.9.8", + "re-resizable": "^6.11.2", "react-color": "^2.19.3", "react-dropzone": "^14.2.3", "react-error-boundary": "^4.0.11", "react-i18next": "^11.18.6", "react-leaflet": "^4.2.1", "react-resizable-panels": "^1.0.10", - "react-resize-detector": "^9.1.1", + "react-resize-detector": "^12.3.0", "react-shadow": "^20.6.0", "rxjs": "^7.3.0", "tailwind-merge": "^2.2.1", diff --git a/packages/graphic-walker/src/App.tsx b/packages/graphic-walker/src/App.tsx index 911e54f46..ef62e22b0 100644 --- a/packages/graphic-walker/src/App.tsx +++ b/packages/graphic-walker/src/App.tsx @@ -36,6 +36,7 @@ import { getComputation } from './computation/clientComputation'; import LogPanel from './fields/datasetFields/logPanel'; import BinPanel from './fields/datasetFields/binPanel'; import RenamePanel from './components/renameField'; +import FieldConfigDialog from './components/fieldConfigDialog'; import { ErrorContext } from './utils/reportError'; import { ErrorBoundary } from 'react-error-boundary'; import Errorpanel from './components/errorpanel'; @@ -53,6 +54,7 @@ import { Tabs, TabsList, TabsTrigger } from './components/ui/tabs'; import { ChartPieIcon, CircleStackIcon, ChatBubbleLeftRightIcon } from '@heroicons/react/24/outline'; import { TabsContent } from '@radix-ui/react-tabs'; import { VegaliteChat } from './components/chat'; +import { ensureBuiltinRendererPlugins, registerRendererPlugin } from './renderer/plugins'; export type BaseVizProps = IAppI18nProps & IVizProps & @@ -100,6 +102,13 @@ export const VizApp = observer(function VizApp(props: BaseVizProps) { const vizStore = useVizStore(); + useEffect(() => { + ensureBuiltinRendererPlugins(); + props.rendererPlugins?.forEach((plugin) => { + registerRendererPlugin(plugin); + }); + }, [props.rendererPlugins]); + useEffect(() => { if (geographicData) { vizStore.setGeographicData(geographicData, geographicData.key); @@ -234,6 +243,7 @@ export const VizApp = observer(function VizApp(props: BaseVizProps) { + {vizStore.showGeoJSONConfigPanel && } @@ -276,6 +286,7 @@ export const VizApp = observer(function VizApp(props: BaseVizProps) { computationFunction={wrappedComputation} // @TODO remove channelScales scales={props.scales ?? props.channelScales} + rendererPlugins={props.rendererPlugins} /> )} diff --git a/packages/graphic-walker/src/Renderer.tsx b/packages/graphic-walker/src/Renderer.tsx index 1697dbda1..a52a384a9 100644 --- a/packages/graphic-walker/src/Renderer.tsx +++ b/packages/graphic-walker/src/Renderer.tsx @@ -156,6 +156,7 @@ export const RendererApp = observer(function VizApp(props: BaseVizProps) { // @TODO remove channelScales scales={props.scales ?? props.channelScales} overrideSize={props.overrideSize} + rendererPlugins={props.rendererPlugins} /> )} diff --git a/packages/graphic-walker/src/components/fieldConfigDialog/index.tsx b/packages/graphic-walker/src/components/fieldConfigDialog/index.tsx new file mode 100644 index 000000000..a19cb01dc --- /dev/null +++ b/packages/graphic-walker/src/components/fieldConfigDialog/index.tsx @@ -0,0 +1,590 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { observer } from 'mobx-react-lite'; +import { useTranslation } from 'react-i18next'; +import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; +import { Textarea } from '@/components/ui/textarea'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import Spinner from '@/components/spinner'; +import Tooltip from '@/components/tooltip'; +import { useCompututaion, useVizStore } from '@/store'; +import { fieldStat } from '@/computation'; +import { GLOBAL_CONFIG } from '@/config'; +import { IAnalyticType, IAggregator, ICustomSortType, IManualSortValue, IPaintMap, IPaintMapV2, ISemanticType, IViewField, ISortMode } from '@/interfaces'; +import { DragDropContext, Draggable, Droppable, DropResult } from '@kanaries/react-beautiful-dnd'; +import { COUNT_FIELD_ID, MEA_KEY_ID, MEA_VAL_ID, PAINT_FIELD_ID } from '@/constants'; +import { getMeaAggName } from '@/utils'; + +type ManualListItem = { + value: IManualSortValue; + id: string; +}; + +const DEFAULT_SORT_ORDER: Exclude = 'ascending'; +type AggregatorSelectValue = IAggregator; + +function asDisplayValue(value: IManualSortValue) { + if (value === null) return 'null'; + if (value === undefined) return ''; + if (typeof value === 'boolean') return value ? 'true' : 'false'; + return String(value); +} + +const FieldConfigDialog = observer(() => { + const vizStore = useVizStore(); + const computation = useCompututaion(); + const { t } = useTranslation(); + + const target = vizStore.fieldConfigTarget; + const isOpen = vizStore.showFieldConfigPanel && Boolean(target); + const currentField: IViewField | null = useMemo(() => { + if (!target) return null; + return vizStore.currentEncodings[target.channel]?.[target.index] ?? null; + }, [target, vizStore.currentEncodings]); + + const [titleOverride, setTitleOverride] = useState(''); + const [sortType, setSortType] = useState('measure'); + const [sortOrder, setSortOrder] = useState('none'); + const [manualValues, setManualValues] = useState([]); + const [loadingValues, setLoadingValues] = useState(false); + const [fetchError, setFetchError] = useState(null); + const [aggValue, setAggValue] = useState('sum'); + const [semanticTypeState, setSemanticTypeState] = useState('nominal'); + const [analyticTypeState, setAnalyticTypeState] = useState('dimension'); + const [customFormat, setCustomFormat] = useState(''); + const [manualSortDisabled, setManualSortDisabled] = useState(false); + const [manualSortDisabledReason, setManualSortDisabledReason] = useState(''); + const [manualValuesFetched, setManualValuesFetched] = useState(false); + const manualSortRequestIdRef = useRef(0); + const [ready, setReady] = useState(false); + + const isMeasureField = analyticTypeState === 'measure'; + const isRowCountField = currentField?.fid === COUNT_FIELD_ID; + const isInnerField = [MEA_VAL_ID, MEA_KEY_ID, PAINT_FIELD_ID].includes(currentField?.fid || ''); + const restrictToTitleAndFormatOnly = Boolean(isRowCountField); + const disableTypeSelectors = Boolean(isInnerField || restrictToTitleAndFormatOnly); + const isFetchingManualValues = loadingValues && sortType === 'manual'; + + const allowSort = useMemo(() => { + if (!target || !currentField) return false; + if (analyticTypeState !== 'dimension') return false; + if (semanticTypeState !== 'nominal' && semanticTypeState !== 'ordinal') return false; + return target.channel === 'rows' || target.channel === 'columns'; + }, [target, analyticTypeState, semanticTypeState]); + + const specialManualValues = useMemo(() => { + if (!currentField) return null; + if (currentField.fid === MEA_KEY_ID) { + const folds = vizStore.config.folds ?? []; + const defaultAggregated = vizStore.config.defaultAggregated; + const meaValField = vizStore.viewMeasures.find((field) => field.fid === MEA_VAL_ID); + if (!folds.length) return []; + return folds + .map((fid) => vizStore.allFields.find((field) => field.fid === fid)) + .filter((field): field is IViewField => Boolean(field)) + .filter((field) => defaultAggregated || field.aggName !== 'expr') + .map((field) => { + if (!defaultAggregated) { + return { + value: field.name, + id: `${MEA_KEY_ID}_${field.fid}`, + } satisfies ManualListItem; + } + const aggName = meaValField?.aggName ?? field.aggName; + return { + value: getMeaAggName(field.name, aggName), + id: `${MEA_KEY_ID}_${field.fid}`, + } satisfies ManualListItem; + }); + } + if (currentField.fid === PAINT_FIELD_ID) { + const mapParam = currentField.expression?.params?.find((param) => param.type === 'map' || param.type === 'newmap'); + if (!mapParam) return []; + const paintMap = mapParam.value as IPaintMap | IPaintMapV2; + const orderSource = paintMap.usedColor && paintMap.usedColor.length ? paintMap.usedColor : Object.keys(paintMap.dict).map((key) => Number(key)); + const colorOrder = orderSource.filter((key, index, arr) => !Number.isNaN(key) && arr.indexOf(key) === index); + return colorOrder + .map((key) => { + const name = paintMap.dict[key]?.name; + if (!name) return null; + return { + value: name, + id: `${PAINT_FIELD_ID}_${key}`, + } as ManualListItem; + }) + .filter((item): item is ManualListItem => Boolean(item)); + } + return null; + }, [currentField?.expression, currentField?.fid, vizStore.allFields, vizStore.config.defaultAggregated, vizStore.config.folds, vizStore.viewMeasures]); + + const aggregatorOptions = useMemo(() => GLOBAL_CONFIG.AGGREGATOR_LIST, []); + const semanticTypeOptions: ISemanticType[] = ['nominal', 'ordinal', 'quantitative', 'temporal']; + const analyticTypeOptions: IAnalyticType[] = ['dimension', 'measure']; + + useEffect(() => { + manualSortRequestIdRef.current += 1; + if (!currentField) { + setTitleOverride(''); + setSortType('measure'); + setSortOrder('none'); + setManualValues([]); + setFetchError(null); + setAggValue('sum'); + setSemanticTypeState('nominal'); + setAnalyticTypeState('dimension'); + setCustomFormat(''); + setManualSortDisabled(false); + setManualSortDisabledReason(''); + setManualValuesFetched(false); + setReady(false); + return; + } + setTitleOverride(currentField.titleOverride ?? ''); + setSortType(currentField.sortType ?? 'measure'); + setSortOrder(currentField.sort ?? 'none'); + if (currentField.fid === MEA_KEY_ID || currentField.fid === PAINT_FIELD_ID) { + setManualValues([]); + } else { + setManualValues((currentField.sortList ?? []).map((value, idx) => ({ value, id: `${currentField.fid}_${idx}` }))); + } + setFetchError(null); + setAggValue((currentField.aggName as AggregatorSelectValue) ?? 'sum'); + setSemanticTypeState(currentField.semanticType); + setAnalyticTypeState(currentField.analyticType); + setCustomFormat(currentField.customFormat ?? ''); + setManualSortDisabled(false); + setManualSortDisabledReason(''); + setManualValuesFetched(false); + setReady(true); + }, [currentField?.fid, target?.channel, target?.index]); + + const handleAnalyticTypeChange = useCallback( + (value: IAnalyticType) => { + setAnalyticTypeState(value); + if (value === 'dimension') { + setAggValue('sum'); + } else if (value === 'measure') { + setAggValue((GLOBAL_CONFIG.AGGREGATOR_LIST[0] as AggregatorSelectValue) ?? 'sum'); + } + }, + [aggValue] + ); + + const hydrateValues = useCallback(async () => { + if (!currentField || !allowSort || !ready || sortType !== 'manual') return; + setFetchError(null); + if (specialManualValues !== null) { + setManualValuesFetched(true); + setLoadingValues(false); + const distinctTotal = specialManualValues.length; + const limitExceeded = distinctTotal > 100; + setManualSortDisabled(limitExceeded); + setManualSortDisabledReason(limitExceeded ? `Manual sort supports up to 100 distinct values. ${distinctTotal} values detected.` : ''); + if (limitExceeded) { + setManualValues([]); + setSortType((prev) => (prev === 'manual' ? 'measure' : prev)); + return; + } + setManualValues((prev) => { + if (prev.length > 0) return prev; + return specialManualValues; + }); + return; + } + const requestId = ++manualSortRequestIdRef.current; + setManualValuesFetched(true); + setLoadingValues(true); + try { + const stats = await fieldStat( + computation, + currentField, + { + values: true, + range: false, + valuesMeta: true, + valuesLimit: 101, + }, + vizStore.meta + ); + if (requestId !== manualSortRequestIdRef.current) { + return; + } + const distinctTotal = stats.valuesMeta?.distinctTotal ?? stats.values.length; + const limitExceeded = distinctTotal > 100; + setManualSortDisabled(limitExceeded); + setManualSortDisabledReason(limitExceeded ? `Manual sort supports up to 100 distinct values. ${distinctTotal} values detected.` : ''); + if (limitExceeded) { + setManualValues([]); + setSortType((prev) => (prev === 'manual' ? 'measure' : prev)); + return; + } + if (stats.values.length) { + setManualValues((prev) => { + if (prev.length > 0) return prev; + return stats.values.map((v, idx) => ({ + value: v.value as IManualSortValue, + id: `${currentField.fid}_auto_${idx}`, + })); + }); + } + } catch (err) { + if (requestId === manualSortRequestIdRef.current) { + setFetchError(err instanceof Error ? err.message : String(err)); + } + } finally { + if (requestId === manualSortRequestIdRef.current) { + setLoadingValues(false); + } + } + }, [allowSort, ready, computation, currentField, sortType, vizStore.meta, specialManualValues]); + + useEffect(() => { + if (isOpen && allowSort && currentField && sortType === 'manual' && !manualValuesFetched && !loadingValues) { + hydrateValues(); + } + }, [allowSort, currentField?.fid, hydrateValues, isOpen, loadingValues, manualValuesFetched, sortType, target?.channel, target?.index]); + + useEffect(() => { + if (!currentField || sortType !== 'manual' || specialManualValues === null) return; + const distinctTotal = specialManualValues.length; + const limitExceeded = distinctTotal > 100; + setManualSortDisabled(limitExceeded); + setManualSortDisabledReason(limitExceeded ? `Manual sort supports up to 100 distinct values. ${distinctTotal} values detected.` : ''); + setManualValuesFetched(true); + setLoadingValues(false); + setFetchError(null); + if (limitExceeded) { + setManualValues([]); + setSortType((prev) => (prev === 'manual' ? 'measure' : prev)); + return; + } + setManualValues((prev) => { + if (prev.length === 0) { + return specialManualValues; + } + const isSame = + prev.length === specialManualValues.length && + prev.every((item, index) => item.id === specialManualValues[index].id && item.value === specialManualValues[index].value); + if (isSame) return prev; + const specialMap = new Map(specialManualValues.map((item) => [item.id, item])); + const ordered: ManualListItem[] = []; + prev.forEach((item) => { + const replacement = specialMap.get(item.id); + if (replacement) { + ordered.push(replacement); + specialMap.delete(item.id); + } + }); + specialMap.forEach((item) => ordered.push(item)); + return ordered; + }); + }, [currentField, sortType, specialManualValues]); + + const handleRetryManualValues = useCallback(() => { + if (loadingValues) return; + setFetchError(null); + setManualValuesFetched(false); + }, [loadingValues]); + + const handleClose = useCallback(() => { + vizStore.setShowFieldConfigPanel(false); + }, [vizStore]); + + const handleSave = useCallback(() => { + if (!target || !currentField) return; + const trimmedTitle = titleOverride.trim(); + const trimmedFormat = customFormat.trim(); + const patch: Partial = { + titleOverride: trimmedTitle ? trimmedTitle : undefined, + semanticType: semanticTypeState, + analyticType: analyticTypeState, + customFormat: trimmedFormat ? trimmedFormat : undefined, + }; + if (analyticTypeState === 'measure') { + patch.aggName = aggValue; + } else if (currentField.aggName) { + patch.aggName = undefined; + } + if (allowSort) { + if (sortType === 'manual') { + patch.sortType = 'manual'; + patch.sortList = manualValues.map((item) => item.value); + patch.sort = patch.sortList.length ? DEFAULT_SORT_ORDER : 'none'; + } else if (sortType === 'alphabetical') { + patch.sortType = 'alphabetical'; + patch.sort = sortOrder === 'none' ? DEFAULT_SORT_ORDER : sortOrder; + patch.sortList = undefined; + } else { + patch.sortType = 'measure'; + patch.sort = sortOrder; + patch.sortList = undefined; + } + } + vizStore.editEncodingField(target.channel, target.index, patch); + handleClose(); + }, [ + allowSort, + currentField, + handleClose, + manualValues, + semanticTypeState, + analyticTypeState, + aggValue, + sortOrder, + sortType, + target, + titleOverride, + customFormat, + vizStore, + ]); + + const reorderManualValue = useCallback((from: number, to: number) => { + setManualValues((prev) => { + if (from === to) return prev; + const next = prev.slice(); + const [item] = next.splice(from, 1); + next.splice(to, 0, item); + return next; + }); + }, []); + + const renderSortOrderControls = () => ( +
+ + setSortOrder(value as ISortMode)} className="flex space-x-3"> +
+ + +
+
+ + +
+ {sortType === 'measure' && ( +
+ + +
+ )} +
+
+ ); + + const handleManualDragEnd = useCallback( + (result: DropResult) => { + if (!result.destination) return; + reorderManualValue(result.source.index, result.destination.index); + }, + [reorderManualValue] + ); + + const manualList = ( +
+
+
+ +
+
+
+
+ Drag to change order +
+ {fetchError && ( +
+ {fetchError} + +
+ )} + + + + {(provided) => ( +
+ {manualValues.map((item, index) => ( + + {(dragProvided, snapshot) => ( +
+ {asDisplayValue(item.value)} +
+ )} +
+ ))} + {provided.placeholder} + {isFetchingManualValues && ( + + + Loading… + + )} +
+ )} +
+
+
+
+
+ ); + + const sortControls = allowSort ? ( +
+
+ + setSortType(value as ICustomSortType)} className="space-y-2 mt-2"> +
+ + +
+
+ + +
+ {manualSortDisabled ? ( + +
+ + +
+
+ ) : ( +
+ + + {isFetchingManualValues && } +
+ )} +
+
+ {(sortType === 'measure' || sortType === 'alphabetical') && renderSortOrderControls()} + {sortType === 'manual' && manualList} +
+ ) : null; + + return ( + (!open ? handleClose() : undefined)}> + + + Field Settings + {currentField &&

{currentField.name}

} +
+ {currentField ? ( +
+
+ +