@open-mercato/ui 0.6.5-develop.4863.1.169bfbb3a3 → 0.6.5-develop.4964.1.ae0edca575
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/backend/filters/AdvancedFilterPanel.js +14 -0
- package/dist/backend/filters/AdvancedFilterPanel.js.map +2 -2
- package/dist/portal/hooks/useTenantContext.js +1 -1
- package/dist/portal/hooks/useTenantContext.js.map +2 -2
- package/package.json +3 -3
- package/src/backend/filters/AdvancedFilterPanel.tsx +27 -0
- package/src/backend/filters/__tests__/AdvancedFilterPanel.test.tsx +98 -0
- package/src/portal/hooks/useTenantContext.ts +7 -2
|
@@ -208,6 +208,20 @@ function AdvancedFilterPanel(props) {
|
|
|
208
208
|
props.onChange(tree);
|
|
209
209
|
props.onOpenChange(false);
|
|
210
210
|
};
|
|
211
|
+
const { onFlush, onOpenChange, open } = props;
|
|
212
|
+
React.useEffect(() => {
|
|
213
|
+
if (!open || pickerOpen || saveDialogOpen) return;
|
|
214
|
+
const closeOnEscape = (event) => {
|
|
215
|
+
if (event.key !== "Escape") return;
|
|
216
|
+
if (typeof document !== "undefined" && document.querySelector('[role="combobox"][aria-expanded="true"]')) {
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
onOpenChange(false);
|
|
220
|
+
onFlush?.();
|
|
221
|
+
};
|
|
222
|
+
window.addEventListener("keydown", closeOnEscape);
|
|
223
|
+
return () => window.removeEventListener("keydown", closeOnEscape);
|
|
224
|
+
}, [onFlush, onOpenChange, open, pickerOpen, saveDialogOpen]);
|
|
211
225
|
React.useEffect(() => {
|
|
212
226
|
if (!savedFilterStorageKey) {
|
|
213
227
|
setSavedFilters([]);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/backend/filters/AdvancedFilterPanel.tsx"],
|
|
4
|
-
"sourcesContent": ["'use client'\nimport * as React from 'react'\nimport { Popover, PopoverAnchor, PopoverContent } from '../../primitives/popover'\nimport { VisuallyHidden } from '@radix-ui/react-visually-hidden'\nimport { Alert, AlertDescription } from '../../primitives/alert'\nimport { Button } from '../../primitives/button'\nimport { Input } from '../../primitives/input'\nimport {\n Dialog,\n DialogContent,\n DialogDescription,\n DialogFooter,\n DialogHeader,\n DialogTitle,\n} from '../../primitives/dialog'\nimport { Filter, Save, Trash2 } from 'lucide-react'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport {\n deserializeTreeFromPersist,\n isPersistedFilterTree,\n serializeTreeForPersist,\n type AdvancedFilterTree,\n type PersistedFilterTree,\n} from '@open-mercato/shared/lib/query/advanced-filter-tree'\nimport type { FilterFieldDef } from '@open-mercato/shared/lib/query/advanced-filter'\nimport { getDefaultOperator } from '@open-mercato/shared/lib/query/advanced-filter'\nimport type { ValidationError } from './filterValidation'\nimport { AdvancedFilterBuilder } from './AdvancedFilterBuilder'\nimport { FilterEmptyState } from './FilterEmptyState'\nimport { QuickFilters, type FilterPreset } from './QuickFilters'\nimport { FilterFieldPicker } from './FilterFieldPicker'\nimport { treeReducer } from './treeReducer'\nimport { flash } from '../FlashMessages'\n\nexport type AdvancedFilterPanelProps = {\n fields: FilterFieldDef[]\n value: AdvancedFilterTree\n onChange: (t: AdvancedFilterTree) => void\n onApply: () => void\n onClear: () => void\n onFlush?: () => void\n pendingErrors: ValidationError[]\n userId: string\n presets: FilterPreset[]\n open: boolean\n onOpenChange: (open: boolean) => void\n triggerRef?: React.RefObject<HTMLElement | null>\n aiSlot?: React.ReactNode\n savedFilterStorageKey?: string\n}\n\ntype SavedAdvancedFilter = {\n id: string\n name: string\n tree: PersistedFilterTree\n createdAt: string\n updatedAt: string\n}\n\n/**\n * Versioned envelope for saved-filter records in localStorage. The `v` discriminator\n * lets future schema changes (rename fields, retention policy, sharing flags) migrate\n * existing records instead of silently dropping them. Bump `v` when adding a breaking\n * change and add a read-old migration branch in `readSavedFilters`.\n */\ntype SavedFiltersEnvelopeV1 = {\n v: 1\n filters: SavedAdvancedFilter[]\n}\n\nconst SAVED_FILTER_STORAGE_PREFIX = 'open-mercato:advanced-filters:'\nconst MAX_SAVED_FILTERS = 20\nconst CURRENT_SAVED_FILTERS_VERSION = 1 as const\n\nfunction makeSavedFilterStorageKey(key?: string): string | null {\n const trimmed = key?.trim()\n return trimmed ? `${SAVED_FILTER_STORAGE_PREFIX}${trimmed}` : null\n}\n\nfunction isSavedAdvancedFilter(value: unknown): value is SavedAdvancedFilter {\n if (!value || typeof value !== 'object') return false\n const record = value as Record<string, unknown>\n return (\n typeof record.id === 'string'\n && typeof record.name === 'string'\n && typeof record.createdAt === 'string'\n && typeof record.updatedAt === 'string'\n && isPersistedFilterTree(record.tree)\n )\n}\n\nfunction readSavedFilters(storageKey: string): SavedAdvancedFilter[] {\n if (typeof window === 'undefined') return []\n try {\n const raw = window.localStorage.getItem(storageKey)\n if (!raw) return []\n const parsed = JSON.parse(raw)\n // v1 envelope (current shape).\n if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {\n const envelope = parsed as { v?: unknown; filters?: unknown }\n if (envelope.v === 1 && Array.isArray(envelope.filters)) {\n return envelope.filters.filter(isSavedAdvancedFilter)\n }\n }\n // Pre-envelope shape (bare array) \u2014 migrate forward on next write.\n if (Array.isArray(parsed)) {\n return parsed.filter(isSavedAdvancedFilter)\n }\n return []\n } catch {\n return []\n }\n}\n\nfunction writeSavedFilters(storageKey: string, filters: SavedAdvancedFilter[]) {\n if (typeof window === 'undefined') return\n const envelope: SavedFiltersEnvelopeV1 = { v: CURRENT_SAVED_FILTERS_VERSION, filters }\n window.localStorage.setItem(storageKey, JSON.stringify(envelope))\n}\n\nfunction SaveFilterDialog({\n open,\n onOpenChange,\n onSave,\n t,\n}: {\n open: boolean\n onOpenChange: (open: boolean) => void\n onSave: (name: string) => Promise<void>\n t: (key: string, fallback: string) => string\n}) {\n const [name, setName] = React.useState('')\n const [saving, setSaving] = React.useState(false)\n const [error, setError] = React.useState<string | null>(null)\n const trimmed = name.trim()\n\n React.useEffect(() => {\n if (!open) {\n setName('')\n setSaving(false)\n setError(null)\n }\n }, [open])\n\n const submit = React.useCallback(async () => {\n if (!trimmed || saving) return\n setSaving(true)\n setError(null)\n try {\n await onSave(trimmed)\n onOpenChange(false)\n } catch (err: unknown) {\n setError(err instanceof Error ? err.message : t('ui.advancedFilter.saveFilter.error', 'Failed to save filter'))\n } finally {\n setSaving(false)\n }\n }, [onSave, onOpenChange, saving, t, trimmed])\n\n return (\n <Dialog open={open} onOpenChange={onOpenChange}>\n {/* `elevated` lifts this dialog above the popover (z-popover=45) \u2014 at default\n z-modal=40 the dialog is occluded by the popover that opens it. */}\n <DialogContent elevated className=\"sm:max-w-md\" data-advanced-filter-portal>\n <form\n className=\"contents\"\n onSubmit={(event) => {\n event.preventDefault()\n void submit()\n }}\n >\n <DialogHeader>\n <DialogTitle>{t('ui.advancedFilter.saveFilter.title', 'Save filter')}</DialogTitle>\n <DialogDescription>\n {t('ui.advancedFilter.saveFilter.description', 'Name this filter so you can reuse the current conditions later.')}\n </DialogDescription>\n </DialogHeader>\n <div className=\"space-y-2\">\n <Input\n value={name}\n onChange={(event) => setName(event.target.value)}\n placeholder={t('ui.advancedFilter.saveFilter.placeholder', 'Filter name')}\n aria-label={t('ui.advancedFilter.saveFilter.nameAriaLabel', 'Filter name')}\n autoFocus\n />\n {error ? <p className=\"text-sm text-status-error-text\">{error}</p> : null}\n </div>\n <DialogFooter>\n <Button type=\"button\" variant=\"outline\" onClick={() => onOpenChange(false)}>\n {t('common.cancel', 'Cancel')}\n </Button>\n <Button type=\"submit\" disabled={!trimmed || saving}>\n {saving ? t('ui.advancedFilter.saveFilter.saving', 'Saving...') : t('ui.advancedFilter.saveFilter.submit', 'Save filter')}\n </Button>\n </DialogFooter>\n </form>\n </DialogContent>\n </Dialog>\n )\n}\n\nfunction SavedFiltersSection({\n filters,\n canSaveCurrent,\n onSaveClick,\n onApply,\n onDelete,\n t,\n}: {\n filters: SavedAdvancedFilter[]\n canSaveCurrent: boolean\n onSaveClick: () => void\n onApply: (filter: SavedAdvancedFilter) => void\n onDelete: (filterId: string) => void\n t: (key: string, fallback: string) => string\n}) {\n if (!canSaveCurrent && filters.length === 0) return null\n return (\n <div className=\"flex flex-col gap-2\" data-testid=\"advanced-filter-saved-filters\">\n <div className=\"flex items-center justify-between gap-3\">\n <div className=\"text-overline font-medium uppercase tracking-widest text-muted-foreground\">\n {t('ui.advancedFilter.savedFilters.heading', 'Saved filters')}\n </div>\n {canSaveCurrent ? (\n <Button\n data-testid=\"advanced-filter-save-trigger\"\n type=\"button\"\n variant=\"outline\"\n size=\"sm\"\n className=\"h-8 rounded-full gap-1.5\"\n onClick={onSaveClick}\n >\n <Save className=\"size-3.5 text-muted-foreground\" />\n {t('ui.advancedFilter.saveFilter.button', 'Save filter')}\n </Button>\n ) : null}\n </div>\n {filters.length ? (\n <div className=\"flex flex-wrap gap-2\">\n {filters.map((filter) => (\n <div key={filter.id} className=\"inline-flex max-w-full items-center overflow-hidden rounded-full border border-border bg-background\">\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n className=\"h-8 min-w-0 rounded-r-none px-3 gap-1.5\"\n title={filter.name}\n onClick={() => onApply(filter)}\n >\n <Filter className=\"size-3.5 shrink-0 text-muted-foreground\" />\n <span className=\"truncate\">{filter.name}</span>\n </Button>\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n className=\"h-8 w-8 rounded-l-none px-0 text-muted-foreground hover:text-status-error-text\"\n aria-label={`${t('ui.advancedFilter.savedFilters.delete', 'Delete saved filter')}: ${filter.name}`}\n onClick={() => onDelete(filter.id)}\n >\n <Trash2 className=\"size-3.5\" />\n </Button>\n </div>\n ))}\n </div>\n ) : (\n <p className=\"text-xs text-muted-foreground\">\n {t('ui.advancedFilter.savedFilters.empty', 'No saved filters yet')}\n </p>\n )}\n </div>\n )\n}\n\nexport function AdvancedFilterPanel(props: AdvancedFilterPanelProps) {\n const t = useT()\n const empty = props.value.root.children.length === 0\n const [pickerOpen, setPickerOpen] = React.useState(false)\n const [saveDialogOpen, setSaveDialogOpen] = React.useState(false)\n const [savedFilters, setSavedFilters] = React.useState<SavedAdvancedFilter[]>([])\n const addConditionRef = React.useRef<HTMLButtonElement>(null)\n const savedFilterStorageKey = React.useMemo(\n () => makeSavedFilterStorageKey(props.savedFilterStorageKey),\n [props.savedFilterStorageKey],\n )\n\n // Flush on close \u2014 the hook's useEffect on close also flushes; this is belt-and-suspenders.\n const wasOpenRef = React.useRef(props.open)\n React.useEffect(() => {\n if (wasOpenRef.current && !props.open && props.onFlush) props.onFlush()\n wasOpenRef.current = props.open\n }, [props.open, props.onFlush])\n\n const handlePresetApply = (tree: AdvancedFilterTree) => {\n props.onChange(tree)\n props.onOpenChange(false)\n }\n\n React.useEffect(() => {\n if (!savedFilterStorageKey) {\n setSavedFilters([])\n return\n }\n if (props.open) {\n setSavedFilters(readSavedFilters(savedFilterStorageKey))\n }\n }, [props.open, savedFilterStorageKey])\n\n const persistSavedFilters = React.useCallback((next: SavedAdvancedFilter[]) => {\n if (!savedFilterStorageKey) return\n setSavedFilters(next)\n writeSavedFilters(savedFilterStorageKey, next)\n }, [savedFilterStorageKey])\n\n const handleSaveFilter = React.useCallback(async (name: string) => {\n if (!savedFilterStorageKey) return\n const now = new Date().toISOString()\n const normalizedName = name.trim()\n const existing = savedFilters.find((item) => item.name.trim().toLowerCase() === normalizedName.toLowerCase())\n const record: SavedAdvancedFilter = {\n id: existing?.id ?? crypto.randomUUID(),\n name: normalizedName,\n tree: serializeTreeForPersist(props.value),\n createdAt: existing?.createdAt ?? now,\n updatedAt: now,\n }\n const next = [\n record,\n ...savedFilters.filter((item) => item.id !== record.id),\n ].slice(0, MAX_SAVED_FILTERS)\n persistSavedFilters(next)\n flash(t('ui.advancedFilter.saveFilter.success', 'Filter saved'), 'success')\n }, [persistSavedFilters, props.value, savedFilterStorageKey, savedFilters, t])\n\n const handleApplySavedFilter = React.useCallback((filter: SavedAdvancedFilter) => {\n const tree = deserializeTreeFromPersist(filter.tree)\n if (!tree) return\n props.onChange(tree)\n props.onOpenChange(false)\n }, [props.onChange, props.onOpenChange])\n\n const handleDeleteSavedFilter = React.useCallback((filterId: string) => {\n persistSavedFilters(savedFilters.filter((item) => item.id !== filterId))\n }, [persistSavedFilters, savedFilters])\n\n const shouldShowSavedFilters = Boolean(savedFilterStorageKey && (!empty || savedFilters.length > 0))\n const savedFiltersSection = shouldShowSavedFilters ? (\n <SavedFiltersSection\n filters={savedFilters}\n canSaveCurrent={!empty}\n onSaveClick={() => setSaveDialogOpen(true)}\n onApply={handleApplySavedFilter}\n onDelete={handleDeleteSavedFilter}\n t={t}\n />\n ) : null\n\n const emptyStateShortcuts = props.presets.length || savedFiltersSection ? (\n <div className=\"flex flex-col gap-4\">\n {props.presets.length ? <QuickFilters presets={props.presets} userId={props.userId} onApply={handlePresetApply} /> : null}\n {savedFiltersSection}\n </div>\n ) : null\n\n const builderErrors = React.useMemo(\n () => props.pendingErrors.map(e => ({ ruleId: e.ruleId, messageKey: e.messageKey, message: e.message })),\n [props.pendingErrors],\n )\n const ignoreAdvancedFilterPortalInteractions = React.useCallback((event: { target: EventTarget | null; preventDefault: () => void }) => {\n const target = event.target\n if (!(target instanceof Element)) return\n if (target.closest('[data-advanced-filter-portal]')) {\n event.preventDefault()\n }\n }, [])\n\n return (\n <Popover open={props.open} onOpenChange={props.onOpenChange}>\n {props.triggerRef ? (\n <PopoverAnchor virtualRef={props.triggerRef as React.RefObject<{ getBoundingClientRect(): DOMRect }>} />\n ) : null}\n <PopoverContent\n className=\"w-[min(calc(100vw-2rem),780px)] max-h-[min(calc(100vh-6rem),720px)] overflow-y-auto p-0 gap-0\"\n align=\"end\"\n sideOffset={8}\n data-testid=\"advanced-filter-panel\"\n onPointerDownOutside={ignoreAdvancedFilterPortalInteractions}\n onFocusOutside={ignoreAdvancedFilterPortalInteractions}\n onInteractOutside={ignoreAdvancedFilterPortalInteractions}\n >\n <VisuallyHidden>\n <h2>{t('ui.advancedFilter.dialog.title', 'Advanced filter')}</h2>\n </VisuallyHidden>\n <div className=\"flex flex-col\">\n {empty ? (\n <FilterEmptyState\n onAddCondition={() => setPickerOpen(true)}\n addConditionRef={addConditionRef}\n aiSlot={props.aiSlot}\n quickFilters={emptyStateShortcuts}\n />\n ) : (\n <>\n <AdvancedFilterBuilder\n fields={props.fields}\n value={props.value}\n onChange={props.onChange}\n onApply={props.onApply}\n onClear={props.onClear}\n pendingErrors={builderErrors}\n />\n {savedFiltersSection ? (\n <div className=\"border-t border-border p-3\">\n {savedFiltersSection}\n </div>\n ) : null}\n </>\n )}\n {props.pendingErrors.length > 0 ? (\n <div className=\"p-3 border-t border-border\" data-testid=\"filter-validation-banner\">\n <Alert variant=\"destructive\">\n <AlertDescription>\n {props.pendingErrors.length === 1\n ? t('ui.advancedFilter.banner.incomplete', '{count} filter is incomplete \u2014 filters won\\'t apply until all values are picked.', { count: 1 })\n : t('ui.advancedFilter.banner.incompletePlural', '{count} filters are incomplete \u2014 filters won\\'t apply until all values are picked.', { count: props.pendingErrors.length })}\n </AlertDescription>\n </Alert>\n </div>\n ) : null}\n </div>\n </PopoverContent>\n <FilterFieldPicker\n fields={props.fields}\n open={pickerOpen}\n onOpenChange={setPickerOpen}\n onSelect={(field) => {\n // Pass defaultOperator so the rule starts with a valid operator for the field's\n // type \u2014 otherwise emptyRule defaults to 'contains', which only matches text.\n const next = treeReducer(props.value, {\n type: 'addRule',\n groupId: props.value.root.id,\n defaultField: field.key,\n defaultOperator: getDefaultOperator(field.type),\n })\n props.onChange(next)\n }}\n triggerRef={addConditionRef}\n />\n <SaveFilterDialog\n open={saveDialogOpen}\n onOpenChange={setSaveDialogOpen}\n onSave={handleSaveFilter}\n t={t}\n />\n </Popover>\n )\n}\n"],
|
|
5
|
-
"mappings": ";AA0KU,
|
|
4
|
+
"sourcesContent": ["'use client'\nimport * as React from 'react'\nimport { Popover, PopoverAnchor, PopoverContent } from '../../primitives/popover'\nimport { VisuallyHidden } from '@radix-ui/react-visually-hidden'\nimport { Alert, AlertDescription } from '../../primitives/alert'\nimport { Button } from '../../primitives/button'\nimport { Input } from '../../primitives/input'\nimport {\n Dialog,\n DialogContent,\n DialogDescription,\n DialogFooter,\n DialogHeader,\n DialogTitle,\n} from '../../primitives/dialog'\nimport { Filter, Save, Trash2 } from 'lucide-react'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport {\n deserializeTreeFromPersist,\n isPersistedFilterTree,\n serializeTreeForPersist,\n type AdvancedFilterTree,\n type PersistedFilterTree,\n} from '@open-mercato/shared/lib/query/advanced-filter-tree'\nimport type { FilterFieldDef } from '@open-mercato/shared/lib/query/advanced-filter'\nimport { getDefaultOperator } from '@open-mercato/shared/lib/query/advanced-filter'\nimport type { ValidationError } from './filterValidation'\nimport { AdvancedFilterBuilder } from './AdvancedFilterBuilder'\nimport { FilterEmptyState } from './FilterEmptyState'\nimport { QuickFilters, type FilterPreset } from './QuickFilters'\nimport { FilterFieldPicker } from './FilterFieldPicker'\nimport { treeReducer } from './treeReducer'\nimport { flash } from '../FlashMessages'\n\nexport type AdvancedFilterPanelProps = {\n fields: FilterFieldDef[]\n value: AdvancedFilterTree\n onChange: (t: AdvancedFilterTree) => void\n onApply: () => void\n onClear: () => void\n onFlush?: () => void\n pendingErrors: ValidationError[]\n userId: string\n presets: FilterPreset[]\n open: boolean\n onOpenChange: (open: boolean) => void\n triggerRef?: React.RefObject<HTMLElement | null>\n aiSlot?: React.ReactNode\n savedFilterStorageKey?: string\n}\n\ntype SavedAdvancedFilter = {\n id: string\n name: string\n tree: PersistedFilterTree\n createdAt: string\n updatedAt: string\n}\n\n/**\n * Versioned envelope for saved-filter records in localStorage. The `v` discriminator\n * lets future schema changes (rename fields, retention policy, sharing flags) migrate\n * existing records instead of silently dropping them. Bump `v` when adding a breaking\n * change and add a read-old migration branch in `readSavedFilters`.\n */\ntype SavedFiltersEnvelopeV1 = {\n v: 1\n filters: SavedAdvancedFilter[]\n}\n\nconst SAVED_FILTER_STORAGE_PREFIX = 'open-mercato:advanced-filters:'\nconst MAX_SAVED_FILTERS = 20\nconst CURRENT_SAVED_FILTERS_VERSION = 1 as const\n\nfunction makeSavedFilterStorageKey(key?: string): string | null {\n const trimmed = key?.trim()\n return trimmed ? `${SAVED_FILTER_STORAGE_PREFIX}${trimmed}` : null\n}\n\nfunction isSavedAdvancedFilter(value: unknown): value is SavedAdvancedFilter {\n if (!value || typeof value !== 'object') return false\n const record = value as Record<string, unknown>\n return (\n typeof record.id === 'string'\n && typeof record.name === 'string'\n && typeof record.createdAt === 'string'\n && typeof record.updatedAt === 'string'\n && isPersistedFilterTree(record.tree)\n )\n}\n\nfunction readSavedFilters(storageKey: string): SavedAdvancedFilter[] {\n if (typeof window === 'undefined') return []\n try {\n const raw = window.localStorage.getItem(storageKey)\n if (!raw) return []\n const parsed = JSON.parse(raw)\n // v1 envelope (current shape).\n if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {\n const envelope = parsed as { v?: unknown; filters?: unknown }\n if (envelope.v === 1 && Array.isArray(envelope.filters)) {\n return envelope.filters.filter(isSavedAdvancedFilter)\n }\n }\n // Pre-envelope shape (bare array) \u2014 migrate forward on next write.\n if (Array.isArray(parsed)) {\n return parsed.filter(isSavedAdvancedFilter)\n }\n return []\n } catch {\n return []\n }\n}\n\nfunction writeSavedFilters(storageKey: string, filters: SavedAdvancedFilter[]) {\n if (typeof window === 'undefined') return\n const envelope: SavedFiltersEnvelopeV1 = { v: CURRENT_SAVED_FILTERS_VERSION, filters }\n window.localStorage.setItem(storageKey, JSON.stringify(envelope))\n}\n\nfunction SaveFilterDialog({\n open,\n onOpenChange,\n onSave,\n t,\n}: {\n open: boolean\n onOpenChange: (open: boolean) => void\n onSave: (name: string) => Promise<void>\n t: (key: string, fallback: string) => string\n}) {\n const [name, setName] = React.useState('')\n const [saving, setSaving] = React.useState(false)\n const [error, setError] = React.useState<string | null>(null)\n const trimmed = name.trim()\n\n React.useEffect(() => {\n if (!open) {\n setName('')\n setSaving(false)\n setError(null)\n }\n }, [open])\n\n const submit = React.useCallback(async () => {\n if (!trimmed || saving) return\n setSaving(true)\n setError(null)\n try {\n await onSave(trimmed)\n onOpenChange(false)\n } catch (err: unknown) {\n setError(err instanceof Error ? err.message : t('ui.advancedFilter.saveFilter.error', 'Failed to save filter'))\n } finally {\n setSaving(false)\n }\n }, [onSave, onOpenChange, saving, t, trimmed])\n\n return (\n <Dialog open={open} onOpenChange={onOpenChange}>\n {/* `elevated` lifts this dialog above the popover (z-popover=45) \u2014 at default\n z-modal=40 the dialog is occluded by the popover that opens it. */}\n <DialogContent elevated className=\"sm:max-w-md\" data-advanced-filter-portal>\n <form\n className=\"contents\"\n onSubmit={(event) => {\n event.preventDefault()\n void submit()\n }}\n >\n <DialogHeader>\n <DialogTitle>{t('ui.advancedFilter.saveFilter.title', 'Save filter')}</DialogTitle>\n <DialogDescription>\n {t('ui.advancedFilter.saveFilter.description', 'Name this filter so you can reuse the current conditions later.')}\n </DialogDescription>\n </DialogHeader>\n <div className=\"space-y-2\">\n <Input\n value={name}\n onChange={(event) => setName(event.target.value)}\n placeholder={t('ui.advancedFilter.saveFilter.placeholder', 'Filter name')}\n aria-label={t('ui.advancedFilter.saveFilter.nameAriaLabel', 'Filter name')}\n autoFocus\n />\n {error ? <p className=\"text-sm text-status-error-text\">{error}</p> : null}\n </div>\n <DialogFooter>\n <Button type=\"button\" variant=\"outline\" onClick={() => onOpenChange(false)}>\n {t('common.cancel', 'Cancel')}\n </Button>\n <Button type=\"submit\" disabled={!trimmed || saving}>\n {saving ? t('ui.advancedFilter.saveFilter.saving', 'Saving...') : t('ui.advancedFilter.saveFilter.submit', 'Save filter')}\n </Button>\n </DialogFooter>\n </form>\n </DialogContent>\n </Dialog>\n )\n}\n\nfunction SavedFiltersSection({\n filters,\n canSaveCurrent,\n onSaveClick,\n onApply,\n onDelete,\n t,\n}: {\n filters: SavedAdvancedFilter[]\n canSaveCurrent: boolean\n onSaveClick: () => void\n onApply: (filter: SavedAdvancedFilter) => void\n onDelete: (filterId: string) => void\n t: (key: string, fallback: string) => string\n}) {\n if (!canSaveCurrent && filters.length === 0) return null\n return (\n <div className=\"flex flex-col gap-2\" data-testid=\"advanced-filter-saved-filters\">\n <div className=\"flex items-center justify-between gap-3\">\n <div className=\"text-overline font-medium uppercase tracking-widest text-muted-foreground\">\n {t('ui.advancedFilter.savedFilters.heading', 'Saved filters')}\n </div>\n {canSaveCurrent ? (\n <Button\n data-testid=\"advanced-filter-save-trigger\"\n type=\"button\"\n variant=\"outline\"\n size=\"sm\"\n className=\"h-8 rounded-full gap-1.5\"\n onClick={onSaveClick}\n >\n <Save className=\"size-3.5 text-muted-foreground\" />\n {t('ui.advancedFilter.saveFilter.button', 'Save filter')}\n </Button>\n ) : null}\n </div>\n {filters.length ? (\n <div className=\"flex flex-wrap gap-2\">\n {filters.map((filter) => (\n <div key={filter.id} className=\"inline-flex max-w-full items-center overflow-hidden rounded-full border border-border bg-background\">\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n className=\"h-8 min-w-0 rounded-r-none px-3 gap-1.5\"\n title={filter.name}\n onClick={() => onApply(filter)}\n >\n <Filter className=\"size-3.5 shrink-0 text-muted-foreground\" />\n <span className=\"truncate\">{filter.name}</span>\n </Button>\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n className=\"h-8 w-8 rounded-l-none px-0 text-muted-foreground hover:text-status-error-text\"\n aria-label={`${t('ui.advancedFilter.savedFilters.delete', 'Delete saved filter')}: ${filter.name}`}\n onClick={() => onDelete(filter.id)}\n >\n <Trash2 className=\"size-3.5\" />\n </Button>\n </div>\n ))}\n </div>\n ) : (\n <p className=\"text-xs text-muted-foreground\">\n {t('ui.advancedFilter.savedFilters.empty', 'No saved filters yet')}\n </p>\n )}\n </div>\n )\n}\n\nexport function AdvancedFilterPanel(props: AdvancedFilterPanelProps) {\n const t = useT()\n const empty = props.value.root.children.length === 0\n const [pickerOpen, setPickerOpen] = React.useState(false)\n const [saveDialogOpen, setSaveDialogOpen] = React.useState(false)\n const [savedFilters, setSavedFilters] = React.useState<SavedAdvancedFilter[]>([])\n const addConditionRef = React.useRef<HTMLButtonElement>(null)\n const savedFilterStorageKey = React.useMemo(\n () => makeSavedFilterStorageKey(props.savedFilterStorageKey),\n [props.savedFilterStorageKey],\n )\n\n // Flush on close \u2014 the hook's useEffect on close also flushes; this is belt-and-suspenders.\n const wasOpenRef = React.useRef(props.open)\n React.useEffect(() => {\n if (wasOpenRef.current && !props.open && props.onFlush) props.onFlush()\n wasOpenRef.current = props.open\n }, [props.open, props.onFlush])\n\n const handlePresetApply = (tree: AdvancedFilterTree) => {\n props.onChange(tree)\n props.onOpenChange(false)\n }\n\n const { onFlush, onOpenChange, open } = props\n\n React.useEffect(() => {\n if (!open || pickerOpen || saveDialogOpen) return\n const closeOnEscape = (event: KeyboardEvent) => {\n if (event.key !== 'Escape') return\n // Do NOT gate on `event.defaultPrevented`. Radix keeps a just-closed\n // dropdown's dismissable layer mounted while its exit animation plays, and\n // that layer still calls preventDefault() on Escape. Picking a value in the\n // operator/value Select therefore leaves a layer that swallows the next\n // Escape, so the panel never closes and the active-filter chips never render\n // (regression seen on loaded standalone CI runners where the exit-animation\n // unmount is delayed). Instead, only defer Escape to an inner dropdown that\n // is *actually* open \u2014 a Radix Select trigger flips aria-expanded to \"false\"\n // synchronously on selection, so a closing-but-animating dropdown is no\n // longer treated as open here.\n if (typeof document !== 'undefined'\n && document.querySelector('[role=\"combobox\"][aria-expanded=\"true\"]')) {\n return\n }\n onOpenChange(false)\n onFlush?.()\n }\n window.addEventListener('keydown', closeOnEscape)\n return () => window.removeEventListener('keydown', closeOnEscape)\n }, [onFlush, onOpenChange, open, pickerOpen, saveDialogOpen])\n\n React.useEffect(() => {\n if (!savedFilterStorageKey) {\n setSavedFilters([])\n return\n }\n if (props.open) {\n setSavedFilters(readSavedFilters(savedFilterStorageKey))\n }\n }, [props.open, savedFilterStorageKey])\n\n const persistSavedFilters = React.useCallback((next: SavedAdvancedFilter[]) => {\n if (!savedFilterStorageKey) return\n setSavedFilters(next)\n writeSavedFilters(savedFilterStorageKey, next)\n }, [savedFilterStorageKey])\n\n const handleSaveFilter = React.useCallback(async (name: string) => {\n if (!savedFilterStorageKey) return\n const now = new Date().toISOString()\n const normalizedName = name.trim()\n const existing = savedFilters.find((item) => item.name.trim().toLowerCase() === normalizedName.toLowerCase())\n const record: SavedAdvancedFilter = {\n id: existing?.id ?? crypto.randomUUID(),\n name: normalizedName,\n tree: serializeTreeForPersist(props.value),\n createdAt: existing?.createdAt ?? now,\n updatedAt: now,\n }\n const next = [\n record,\n ...savedFilters.filter((item) => item.id !== record.id),\n ].slice(0, MAX_SAVED_FILTERS)\n persistSavedFilters(next)\n flash(t('ui.advancedFilter.saveFilter.success', 'Filter saved'), 'success')\n }, [persistSavedFilters, props.value, savedFilterStorageKey, savedFilters, t])\n\n const handleApplySavedFilter = React.useCallback((filter: SavedAdvancedFilter) => {\n const tree = deserializeTreeFromPersist(filter.tree)\n if (!tree) return\n props.onChange(tree)\n props.onOpenChange(false)\n }, [props.onChange, props.onOpenChange])\n\n const handleDeleteSavedFilter = React.useCallback((filterId: string) => {\n persistSavedFilters(savedFilters.filter((item) => item.id !== filterId))\n }, [persistSavedFilters, savedFilters])\n\n const shouldShowSavedFilters = Boolean(savedFilterStorageKey && (!empty || savedFilters.length > 0))\n const savedFiltersSection = shouldShowSavedFilters ? (\n <SavedFiltersSection\n filters={savedFilters}\n canSaveCurrent={!empty}\n onSaveClick={() => setSaveDialogOpen(true)}\n onApply={handleApplySavedFilter}\n onDelete={handleDeleteSavedFilter}\n t={t}\n />\n ) : null\n\n const emptyStateShortcuts = props.presets.length || savedFiltersSection ? (\n <div className=\"flex flex-col gap-4\">\n {props.presets.length ? <QuickFilters presets={props.presets} userId={props.userId} onApply={handlePresetApply} /> : null}\n {savedFiltersSection}\n </div>\n ) : null\n\n const builderErrors = React.useMemo(\n () => props.pendingErrors.map(e => ({ ruleId: e.ruleId, messageKey: e.messageKey, message: e.message })),\n [props.pendingErrors],\n )\n const ignoreAdvancedFilterPortalInteractions = React.useCallback((event: { target: EventTarget | null; preventDefault: () => void }) => {\n const target = event.target\n if (!(target instanceof Element)) return\n if (target.closest('[data-advanced-filter-portal]')) {\n event.preventDefault()\n }\n }, [])\n\n return (\n <Popover open={props.open} onOpenChange={props.onOpenChange}>\n {props.triggerRef ? (\n <PopoverAnchor virtualRef={props.triggerRef as React.RefObject<{ getBoundingClientRect(): DOMRect }>} />\n ) : null}\n <PopoverContent\n className=\"w-[min(calc(100vw-2rem),780px)] max-h-[min(calc(100vh-6rem),720px)] overflow-y-auto p-0 gap-0\"\n align=\"end\"\n sideOffset={8}\n data-testid=\"advanced-filter-panel\"\n onPointerDownOutside={ignoreAdvancedFilterPortalInteractions}\n onFocusOutside={ignoreAdvancedFilterPortalInteractions}\n onInteractOutside={ignoreAdvancedFilterPortalInteractions}\n >\n <VisuallyHidden>\n <h2>{t('ui.advancedFilter.dialog.title', 'Advanced filter')}</h2>\n </VisuallyHidden>\n <div className=\"flex flex-col\">\n {empty ? (\n <FilterEmptyState\n onAddCondition={() => setPickerOpen(true)}\n addConditionRef={addConditionRef}\n aiSlot={props.aiSlot}\n quickFilters={emptyStateShortcuts}\n />\n ) : (\n <>\n <AdvancedFilterBuilder\n fields={props.fields}\n value={props.value}\n onChange={props.onChange}\n onApply={props.onApply}\n onClear={props.onClear}\n pendingErrors={builderErrors}\n />\n {savedFiltersSection ? (\n <div className=\"border-t border-border p-3\">\n {savedFiltersSection}\n </div>\n ) : null}\n </>\n )}\n {props.pendingErrors.length > 0 ? (\n <div className=\"p-3 border-t border-border\" data-testid=\"filter-validation-banner\">\n <Alert variant=\"destructive\">\n <AlertDescription>\n {props.pendingErrors.length === 1\n ? t('ui.advancedFilter.banner.incomplete', '{count} filter is incomplete \u2014 filters won\\'t apply until all values are picked.', { count: 1 })\n : t('ui.advancedFilter.banner.incompletePlural', '{count} filters are incomplete \u2014 filters won\\'t apply until all values are picked.', { count: props.pendingErrors.length })}\n </AlertDescription>\n </Alert>\n </div>\n ) : null}\n </div>\n </PopoverContent>\n <FilterFieldPicker\n fields={props.fields}\n open={pickerOpen}\n onOpenChange={setPickerOpen}\n onSelect={(field) => {\n // Pass defaultOperator so the rule starts with a valid operator for the field's\n // type \u2014 otherwise emptyRule defaults to 'contains', which only matches text.\n const next = treeReducer(props.value, {\n type: 'addRule',\n groupId: props.value.root.id,\n defaultField: field.key,\n defaultOperator: getDefaultOperator(field.type),\n })\n props.onChange(next)\n }}\n triggerRef={addConditionRef}\n />\n <SaveFilterDialog\n open={saveDialogOpen}\n onOpenChange={setSaveDialogOpen}\n onSave={handleSaveFilter}\n t={t}\n />\n </Popover>\n )\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/portal/hooks/useTenantContext.ts"],
|
|
4
|
-
"sourcesContent": ["\"use client\"\nimport { useEffect, useState } from 'react'\nimport { apiCall } from '../../backend/utils/apiCall'\n\ntype OrgContext = {\n tenantId: string | undefined\n organizationId: string | undefined\n organizationName: string | undefined\n loading: boolean\n error: string | null\n}\n\n/**\n * Client-side hook for resolving organization/tenant context by slug.\n *\n * Used in portal pages before login/signup to resolve the tenant and organization\n * from the URL slug via `/api/directory/organizations/lookup`.\n *\n * @param orgSlug - Organization slug from the URL\n *\n * @example\n * ```tsx\n * import { useTenantContext } from '@open-mercato/ui/portal/hooks/useTenantContext'\n *\n * function LoginPage({ orgSlug }: { orgSlug: string }) {\n * const { tenantId, organizationId, organizationName, loading, error } = useTenantContext(orgSlug)\n * if (loading) return <LoadingMessage />\n * if (error) return <ErrorMessage error={error} />\n * // ...\n * }\n * ```\n */\nexport function useTenantContext(orgSlug: string): OrgContext {\n const [state, setState] = useState<OrgContext>({\n tenantId: undefined,\n organizationId: undefined,\n organizationName: undefined,\n loading: true,\n error: null,\n })\n\n useEffect(() => {\n let cancelled = false\n\n async function lookup() {\n if (!orgSlug) {\n setState((prev) => ({ ...prev, loading: false, error: 'No organization slug provided.' }))\n return\n }\n\n try {\n const { ok, result: data } = await apiCall<{ ok: boolean; organization: { id: string;
|
|
5
|
-
"mappings": ";AACA,SAAS,WAAW,gBAAgB;AACpC,SAAS,eAAe;
|
|
4
|
+
"sourcesContent": ["\"use client\"\nimport { useEffect, useState } from 'react'\nimport { apiCall } from '../../backend/utils/apiCall'\n\ntype OrgContext = {\n /**\n * @deprecated The public org lookup no longer returns a tenant id; portal\n * flows send `organizationId` and the server resolves the tenant. Always\n * `undefined` \u2014 kept for backward compatibility with existing consumers.\n */\n tenantId: string | undefined\n organizationId: string | undefined\n organizationName: string | undefined\n loading: boolean\n error: string | null\n}\n\n/**\n * Client-side hook for resolving organization/tenant context by slug.\n *\n * Used in portal pages before login/signup to resolve the tenant and organization\n * from the URL slug via `/api/directory/organizations/lookup`.\n *\n * @param orgSlug - Organization slug from the URL\n *\n * @example\n * ```tsx\n * import { useTenantContext } from '@open-mercato/ui/portal/hooks/useTenantContext'\n *\n * function LoginPage({ orgSlug }: { orgSlug: string }) {\n * const { tenantId, organizationId, organizationName, loading, error } = useTenantContext(orgSlug)\n * if (loading) return <LoadingMessage />\n * if (error) return <ErrorMessage error={error} />\n * // ...\n * }\n * ```\n */\nexport function useTenantContext(orgSlug: string): OrgContext {\n const [state, setState] = useState<OrgContext>({\n tenantId: undefined,\n organizationId: undefined,\n organizationName: undefined,\n loading: true,\n error: null,\n })\n\n useEffect(() => {\n let cancelled = false\n\n async function lookup() {\n if (!orgSlug) {\n setState((prev) => ({ ...prev, loading: false, error: 'No organization slug provided.' }))\n return\n }\n\n try {\n const { ok, result: data } = await apiCall<{ ok: boolean; organization: { id: string; name: string }; error?: string }>(`/api/directory/organizations/lookup?slug=${encodeURIComponent(orgSlug)}`)\n if (cancelled) return\n\n if (!ok || !data?.ok || !data.organization) {\n setState((prev) => ({\n ...prev,\n loading: false,\n error: data?.error || 'Organization not found.',\n }))\n return\n }\n\n setState({\n tenantId: undefined,\n organizationId: data.organization.id,\n organizationName: data.organization.name,\n loading: false,\n error: null,\n })\n } catch {\n if (!cancelled) {\n setState((prev) => ({ ...prev, loading: false, error: 'Failed to load organization.' }))\n }\n }\n }\n\n lookup()\n return () => { cancelled = true }\n }, [orgSlug])\n\n return state\n}\n"],
|
|
5
|
+
"mappings": ";AACA,SAAS,WAAW,gBAAgB;AACpC,SAAS,eAAe;AAmCjB,SAAS,iBAAiB,SAA6B;AAC5D,QAAM,CAAC,OAAO,QAAQ,IAAI,SAAqB;AAAA,IAC7C,UAAU;AAAA,IACV,gBAAgB;AAAA,IAChB,kBAAkB;AAAA,IAClB,SAAS;AAAA,IACT,OAAO;AAAA,EACT,CAAC;AAED,YAAU,MAAM;AACd,QAAI,YAAY;AAEhB,mBAAe,SAAS;AACtB,UAAI,CAAC,SAAS;AACZ,iBAAS,CAAC,UAAU,EAAE,GAAG,MAAM,SAAS,OAAO,OAAO,iCAAiC,EAAE;AACzF;AAAA,MACF;AAEA,UAAI;AACF,cAAM,EAAE,IAAI,QAAQ,KAAK,IAAI,MAAM,QAAqF,4CAA4C,mBAAmB,OAAO,CAAC,EAAE;AACjM,YAAI,UAAW;AAEf,YAAI,CAAC,MAAM,CAAC,MAAM,MAAM,CAAC,KAAK,cAAc;AAC1C,mBAAS,CAAC,UAAU;AAAA,YAClB,GAAG;AAAA,YACH,SAAS;AAAA,YACT,OAAO,MAAM,SAAS;AAAA,UACxB,EAAE;AACF;AAAA,QACF;AAEA,iBAAS;AAAA,UACP,UAAU;AAAA,UACV,gBAAgB,KAAK,aAAa;AAAA,UAClC,kBAAkB,KAAK,aAAa;AAAA,UACpC,SAAS;AAAA,UACT,OAAO;AAAA,QACT,CAAC;AAAA,MACH,QAAQ;AACN,YAAI,CAAC,WAAW;AACd,mBAAS,CAAC,UAAU,EAAE,GAAG,MAAM,SAAS,OAAO,OAAO,+BAA+B,EAAE;AAAA,QACzF;AAAA,MACF;AAAA,IACF;AAEA,WAAO;AACP,WAAO,MAAM;AAAE,kBAAY;AAAA,IAAK;AAAA,EAClC,GAAG,CAAC,OAAO,CAAC;AAEZ,SAAO;AACT;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/ui",
|
|
3
|
-
"version": "0.6.5-develop.
|
|
3
|
+
"version": "0.6.5-develop.4964.1.ae0edca575",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -149,13 +149,13 @@
|
|
|
149
149
|
"remark-gfm": "^4.0.1"
|
|
150
150
|
},
|
|
151
151
|
"peerDependencies": {
|
|
152
|
-
"@open-mercato/shared": "0.6.5-develop.
|
|
152
|
+
"@open-mercato/shared": "0.6.5-develop.4964.1.ae0edca575",
|
|
153
153
|
"react": ">=18.0.0",
|
|
154
154
|
"react-dom": ">=18.0.0",
|
|
155
155
|
"react-is": ">=18.0.0"
|
|
156
156
|
},
|
|
157
157
|
"devDependencies": {
|
|
158
|
-
"@open-mercato/shared": "0.6.5-develop.
|
|
158
|
+
"@open-mercato/shared": "0.6.5-develop.4964.1.ae0edca575",
|
|
159
159
|
"@testing-library/dom": "^10.4.1",
|
|
160
160
|
"@testing-library/jest-dom": "^6.9.1",
|
|
161
161
|
"@testing-library/react": "^16.3.1",
|
|
@@ -295,6 +295,33 @@ export function AdvancedFilterPanel(props: AdvancedFilterPanelProps) {
|
|
|
295
295
|
props.onOpenChange(false)
|
|
296
296
|
}
|
|
297
297
|
|
|
298
|
+
const { onFlush, onOpenChange, open } = props
|
|
299
|
+
|
|
300
|
+
React.useEffect(() => {
|
|
301
|
+
if (!open || pickerOpen || saveDialogOpen) return
|
|
302
|
+
const closeOnEscape = (event: KeyboardEvent) => {
|
|
303
|
+
if (event.key !== 'Escape') return
|
|
304
|
+
// Do NOT gate on `event.defaultPrevented`. Radix keeps a just-closed
|
|
305
|
+
// dropdown's dismissable layer mounted while its exit animation plays, and
|
|
306
|
+
// that layer still calls preventDefault() on Escape. Picking a value in the
|
|
307
|
+
// operator/value Select therefore leaves a layer that swallows the next
|
|
308
|
+
// Escape, so the panel never closes and the active-filter chips never render
|
|
309
|
+
// (regression seen on loaded standalone CI runners where the exit-animation
|
|
310
|
+
// unmount is delayed). Instead, only defer Escape to an inner dropdown that
|
|
311
|
+
// is *actually* open — a Radix Select trigger flips aria-expanded to "false"
|
|
312
|
+
// synchronously on selection, so a closing-but-animating dropdown is no
|
|
313
|
+
// longer treated as open here.
|
|
314
|
+
if (typeof document !== 'undefined'
|
|
315
|
+
&& document.querySelector('[role="combobox"][aria-expanded="true"]')) {
|
|
316
|
+
return
|
|
317
|
+
}
|
|
318
|
+
onOpenChange(false)
|
|
319
|
+
onFlush?.()
|
|
320
|
+
}
|
|
321
|
+
window.addEventListener('keydown', closeOnEscape)
|
|
322
|
+
return () => window.removeEventListener('keydown', closeOnEscape)
|
|
323
|
+
}, [onFlush, onOpenChange, open, pickerOpen, saveDialogOpen])
|
|
324
|
+
|
|
298
325
|
React.useEffect(() => {
|
|
299
326
|
if (!savedFilterStorageKey) {
|
|
300
327
|
setSavedFilters([])
|
|
@@ -106,6 +106,74 @@ describe('AdvancedFilterPanel', () => {
|
|
|
106
106
|
expect(screen.queryByText(/no filters applied/i)).not.toBeInTheDocument()
|
|
107
107
|
})
|
|
108
108
|
|
|
109
|
+
it('closes the panel on Escape even when a closing dropdown layer marked the event prevented', () => {
|
|
110
|
+
const onOpenChange = jest.fn()
|
|
111
|
+
const onFlush = jest.fn()
|
|
112
|
+
render(
|
|
113
|
+
<AdvancedFilterPanel
|
|
114
|
+
fields={fields}
|
|
115
|
+
value={createEmptyTree()}
|
|
116
|
+
onChange={() => {}}
|
|
117
|
+
onApply={() => {}}
|
|
118
|
+
onClear={() => {}}
|
|
119
|
+
onFlush={onFlush}
|
|
120
|
+
pendingErrors={[]}
|
|
121
|
+
userId="u1"
|
|
122
|
+
presets={[]}
|
|
123
|
+
open
|
|
124
|
+
onOpenChange={onOpenChange}
|
|
125
|
+
triggerRef={{ current: null }}
|
|
126
|
+
/>,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
// Simulate a just-closed Select whose dismissable layer is still mounted during
|
|
130
|
+
// its exit animation: it consumes Escape (defaultPrevented) but its trigger has
|
|
131
|
+
// already flipped aria-expanded to "false". The panel must still close.
|
|
132
|
+
const swallow = (event: KeyboardEvent) => {
|
|
133
|
+
if (event.key === 'Escape') event.preventDefault()
|
|
134
|
+
}
|
|
135
|
+
window.addEventListener('keydown', swallow, true)
|
|
136
|
+
try {
|
|
137
|
+
fireEvent.keyDown(window, { key: 'Escape' })
|
|
138
|
+
} finally {
|
|
139
|
+
window.removeEventListener('keydown', swallow, true)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
expect(onOpenChange).toHaveBeenCalledWith(false)
|
|
143
|
+
expect(onFlush).toHaveBeenCalled()
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('keeps the panel open on Escape while an inner dropdown is actually open', () => {
|
|
147
|
+
const onOpenChange = jest.fn()
|
|
148
|
+
render(
|
|
149
|
+
<AdvancedFilterPanel
|
|
150
|
+
fields={fields}
|
|
151
|
+
value={createEmptyTree()}
|
|
152
|
+
onChange={() => {}}
|
|
153
|
+
onApply={() => {}}
|
|
154
|
+
onClear={() => {}}
|
|
155
|
+
pendingErrors={[]}
|
|
156
|
+
userId="u1"
|
|
157
|
+
presets={[]}
|
|
158
|
+
open
|
|
159
|
+
onOpenChange={onOpenChange}
|
|
160
|
+
triggerRef={{ current: null }}
|
|
161
|
+
/>,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
// A genuinely-open Radix Select keeps its trigger at aria-expanded="true".
|
|
165
|
+
const openSelect = document.createElement('div')
|
|
166
|
+
openSelect.setAttribute('role', 'combobox')
|
|
167
|
+
openSelect.setAttribute('aria-expanded', 'true')
|
|
168
|
+
document.body.appendChild(openSelect)
|
|
169
|
+
try {
|
|
170
|
+
fireEvent.keyDown(window, { key: 'Escape' })
|
|
171
|
+
expect(onOpenChange).not.toHaveBeenCalled()
|
|
172
|
+
} finally {
|
|
173
|
+
document.body.removeChild(openSelect)
|
|
174
|
+
}
|
|
175
|
+
})
|
|
176
|
+
|
|
109
177
|
it('saves filters separately from perspectives', async () => {
|
|
110
178
|
const tree = { root: { id: 'r', type: 'group' as const, combinator: 'and' as const, children: [
|
|
111
179
|
{ id: 'a', type: 'rule' as const, field: 'name', operator: 'contains' as const, value: 'Alice' } as any,
|
|
@@ -209,4 +277,34 @@ describe('AdvancedFilterPanel', () => {
|
|
|
209
277
|
}))
|
|
210
278
|
expect(onOpenChange).toHaveBeenCalledWith(false)
|
|
211
279
|
})
|
|
280
|
+
|
|
281
|
+
it('closes and flushes the panel when Escape is pressed after manual filter editing', () => {
|
|
282
|
+
const onOpenChange = jest.fn()
|
|
283
|
+
const onFlush = jest.fn()
|
|
284
|
+
const tree = { root: { id: 'r', type: 'group' as const, combinator: 'and' as const, children: [
|
|
285
|
+
{ id: 'a', type: 'rule' as const, field: 'name', operator: 'contains' as const, value: 'Alice' } as any,
|
|
286
|
+
] } }
|
|
287
|
+
|
|
288
|
+
render(
|
|
289
|
+
<AdvancedFilterPanel
|
|
290
|
+
fields={fields}
|
|
291
|
+
value={tree}
|
|
292
|
+
onChange={() => {}}
|
|
293
|
+
onApply={() => {}}
|
|
294
|
+
onClear={() => {}}
|
|
295
|
+
onFlush={onFlush}
|
|
296
|
+
pendingErrors={[]}
|
|
297
|
+
userId="u1"
|
|
298
|
+
presets={[]}
|
|
299
|
+
open
|
|
300
|
+
onOpenChange={onOpenChange}
|
|
301
|
+
triggerRef={{ current: null }}
|
|
302
|
+
/>,
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
fireEvent.keyDown(window, { key: 'Escape' })
|
|
306
|
+
|
|
307
|
+
expect(onOpenChange).toHaveBeenCalledWith(false)
|
|
308
|
+
expect(onFlush).toHaveBeenCalledTimes(1)
|
|
309
|
+
})
|
|
212
310
|
})
|
|
@@ -3,6 +3,11 @@ import { useEffect, useState } from 'react'
|
|
|
3
3
|
import { apiCall } from '../../backend/utils/apiCall'
|
|
4
4
|
|
|
5
5
|
type OrgContext = {
|
|
6
|
+
/**
|
|
7
|
+
* @deprecated The public org lookup no longer returns a tenant id; portal
|
|
8
|
+
* flows send `organizationId` and the server resolves the tenant. Always
|
|
9
|
+
* `undefined` — kept for backward compatibility with existing consumers.
|
|
10
|
+
*/
|
|
6
11
|
tenantId: string | undefined
|
|
7
12
|
organizationId: string | undefined
|
|
8
13
|
organizationName: string | undefined
|
|
@@ -49,7 +54,7 @@ export function useTenantContext(orgSlug: string): OrgContext {
|
|
|
49
54
|
}
|
|
50
55
|
|
|
51
56
|
try {
|
|
52
|
-
const { ok, result: data } = await apiCall<{ ok: boolean; organization: { id: string;
|
|
57
|
+
const { ok, result: data } = await apiCall<{ ok: boolean; organization: { id: string; name: string }; error?: string }>(`/api/directory/organizations/lookup?slug=${encodeURIComponent(orgSlug)}`)
|
|
53
58
|
if (cancelled) return
|
|
54
59
|
|
|
55
60
|
if (!ok || !data?.ok || !data.organization) {
|
|
@@ -62,7 +67,7 @@ export function useTenantContext(orgSlug: string): OrgContext {
|
|
|
62
67
|
}
|
|
63
68
|
|
|
64
69
|
setState({
|
|
65
|
-
tenantId:
|
|
70
|
+
tenantId: undefined,
|
|
66
71
|
organizationId: data.organization.id,
|
|
67
72
|
organizationName: data.organization.name,
|
|
68
73
|
loading: false,
|