@open-mercato/ui 0.6.5-develop.4782.1.29bd722d3d → 0.6.5-develop.4790.1.bffb4fd44b
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/.turbo/turbo-build.log +1 -1
- package/dist/backend/DataTable.js +46 -14
- package/dist/backend/DataTable.js.map +2 -2
- package/dist/backend/filters/FilteredEmptyResults.js +12 -4
- package/dist/backend/filters/FilteredEmptyResults.js.map +2 -2
- package/dist/backend/filters/ListEmptyState.js +44 -0
- package/dist/backend/filters/ListEmptyState.js.map +7 -0
- package/dist/backend/filters/SearchEmptyResults.js +28 -0
- package/dist/backend/filters/SearchEmptyResults.js.map +7 -0
- package/package.json +3 -3
- package/src/backend/DataTable.tsx +57 -22
- package/src/backend/filters/FilteredEmptyResults.tsx +23 -10
- package/src/backend/filters/ListEmptyState.tsx +69 -0
- package/src/backend/filters/SearchEmptyResults.tsx +42 -0
- package/src/backend/filters/__tests__/ListEmptyState.test.tsx +53 -0
|
@@ -3,16 +3,24 @@ import { jsx, jsxs } from "react/jsx-runtime";
|
|
|
3
3
|
import { SearchX, Undo2 } from "lucide-react";
|
|
4
4
|
import { Button } from "../../primitives/button.js";
|
|
5
5
|
import { useT } from "@open-mercato/shared/lib/i18n/context";
|
|
6
|
-
function FilteredEmptyResults({ entityNamePlural, canRemoveLast, onClearAll, onRemoveLast }) {
|
|
6
|
+
function FilteredEmptyResults({ entityNamePlural, canRemoveLast, onClearAll, onRemoveLast, onClearSearch }) {
|
|
7
7
|
const t = useT();
|
|
8
|
+
const searchActive = typeof onClearSearch === "function";
|
|
9
|
+
const title = searchActive ? t("ui.advancedFilter.empty.noMatchesSearchFilters", "No {entity} match your search and filters", { entity: entityNamePlural }) : t("ui.advancedFilter.empty.noMatches", "No {entity} match these filters", { entity: entityNamePlural });
|
|
10
|
+
const description = searchActive ? t("ui.advancedFilter.empty.tryRemovingSearchFilters", "Try a different search, remove a filter, or clear everything to see everyone.") : t("ui.advancedFilter.empty.tryRemoving", "Try removing the most restrictive filter, or clear all filters to see everyone.");
|
|
11
|
+
const primaryLabel = searchActive ? t("ui.advancedFilter.empty.clearSearchAndFilters", "Clear search and filters") : t("ui.advancedFilter.empty.clearAll", "Clear all filters");
|
|
12
|
+
const handlePrimary = searchActive ? () => {
|
|
13
|
+
onClearSearch?.();
|
|
14
|
+
onClearAll();
|
|
15
|
+
} : onClearAll;
|
|
8
16
|
return /* @__PURE__ */ jsxs("div", { className: "flex flex-col items-center justify-center gap-4 py-12", "data-testid": "filtered-empty-results", children: [
|
|
9
17
|
/* @__PURE__ */ jsx("div", { className: "flex size-14 items-center justify-center rounded-full bg-muted", children: /* @__PURE__ */ jsx(SearchX, { className: "size-6 text-muted-foreground" }) }),
|
|
10
18
|
/* @__PURE__ */ jsxs("div", { className: "flex flex-col items-center gap-1", children: [
|
|
11
|
-
/* @__PURE__ */ jsx("div", { className: "text-base font-semibold", children:
|
|
12
|
-
/* @__PURE__ */ jsx("div", { className: "text-sm text-muted-foreground max-w-md text-center", children:
|
|
19
|
+
/* @__PURE__ */ jsx("div", { className: "text-base font-semibold", children: title }),
|
|
20
|
+
/* @__PURE__ */ jsx("div", { className: "text-sm text-muted-foreground max-w-md text-center", children: description })
|
|
13
21
|
] }),
|
|
14
22
|
/* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
|
|
15
|
-
/* @__PURE__ */ jsx(Button, { type: "button", onClick:
|
|
23
|
+
/* @__PURE__ */ jsx(Button, { type: "button", onClick: handlePrimary, children: primaryLabel }),
|
|
16
24
|
/* @__PURE__ */ jsxs(Button, { type: "button", variant: "outline", disabled: !canRemoveLast, onClick: onRemoveLast, children: [
|
|
17
25
|
/* @__PURE__ */ jsx(Undo2, { className: "size-4" }),
|
|
18
26
|
t("ui.advancedFilter.empty.removeLast", "Remove last filter")
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/backend/filters/FilteredEmptyResults.tsx"],
|
|
4
|
-
"sourcesContent": ["'use client'\nimport * as React from 'react'\nimport { SearchX, Undo2 } from 'lucide-react'\nimport { Button } from '../../primitives/button'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\n\nexport type FilteredEmptyResultsProps = {\n entityNamePlural: string\n canRemoveLast: boolean\n onClearAll: () => void\n onRemoveLast: () => void\n}\n\nexport function FilteredEmptyResults({ entityNamePlural, canRemoveLast, onClearAll, onRemoveLast }: FilteredEmptyResultsProps) {\n const t = useT()\n return (\n <div className=\"flex flex-col items-center justify-center gap-4 py-12\" data-testid=\"filtered-empty-results\">\n <div className=\"flex size-14 items-center justify-center rounded-full bg-muted\">\n <SearchX className=\"size-6 text-muted-foreground\" />\n </div>\n <div className=\"flex flex-col items-center gap-1\">\n <div className=\"text-base font-semibold\"
|
|
5
|
-
"mappings": ";
|
|
4
|
+
"sourcesContent": ["'use client'\nimport * as React from 'react'\nimport { SearchX, Undo2 } from 'lucide-react'\nimport { Button } from '../../primitives/button'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\n\nexport type FilteredEmptyResultsProps = {\n entityNamePlural: string\n canRemoveLast: boolean\n onClearAll: () => void\n onRemoveLast: () => void\n /**\n * When provided, a search is ALSO active alongside the filters. Switches the\n * copy to a combined search+filter message and makes the primary action clear\n * both the search and the filters (so a single click shows everyone).\n */\n onClearSearch?: () => void\n}\n\nexport function FilteredEmptyResults({ entityNamePlural, canRemoveLast, onClearAll, onRemoveLast, onClearSearch }: FilteredEmptyResultsProps) {\n const t = useT()\n const searchActive = typeof onClearSearch === 'function'\n const title = searchActive\n ? t('ui.advancedFilter.empty.noMatchesSearchFilters', 'No {entity} match your search and filters', { entity: entityNamePlural })\n : t('ui.advancedFilter.empty.noMatches', 'No {entity} match these filters', { entity: entityNamePlural })\n const description = searchActive\n ? t('ui.advancedFilter.empty.tryRemovingSearchFilters', 'Try a different search, remove a filter, or clear everything to see everyone.')\n : t('ui.advancedFilter.empty.tryRemoving', 'Try removing the most restrictive filter, or clear all filters to see everyone.')\n const primaryLabel = searchActive\n ? t('ui.advancedFilter.empty.clearSearchAndFilters', 'Clear search and filters')\n : t('ui.advancedFilter.empty.clearAll', 'Clear all filters')\n const handlePrimary = searchActive\n ? () => { onClearSearch?.(); onClearAll() }\n : onClearAll\n return (\n <div className=\"flex flex-col items-center justify-center gap-4 py-12\" data-testid=\"filtered-empty-results\">\n <div className=\"flex size-14 items-center justify-center rounded-full bg-muted\">\n <SearchX className=\"size-6 text-muted-foreground\" />\n </div>\n <div className=\"flex flex-col items-center gap-1\">\n <div className=\"text-base font-semibold\">{title}</div>\n <div className=\"text-sm text-muted-foreground max-w-md text-center\">{description}</div>\n </div>\n <div className=\"flex items-center gap-2\">\n <Button type=\"button\" onClick={handlePrimary}>{primaryLabel}</Button>\n <Button type=\"button\" variant=\"outline\" disabled={!canRemoveLast} onClick={onRemoveLast}>\n <Undo2 className=\"size-4\" />\n {t('ui.advancedFilter.empty.removeLast', 'Remove last filter')}\n </Button>\n </div>\n </div>\n )\n}\n"],
|
|
5
|
+
"mappings": ";AAqCQ,cAEF,YAFE;AAnCR,SAAS,SAAS,aAAa;AAC/B,SAAS,cAAc;AACvB,SAAS,YAAY;AAed,SAAS,qBAAqB,EAAE,kBAAkB,eAAe,YAAY,cAAc,cAAc,GAA8B;AAC5I,QAAM,IAAI,KAAK;AACf,QAAM,eAAe,OAAO,kBAAkB;AAC9C,QAAM,QAAQ,eACV,EAAE,kDAAkD,6CAA6C,EAAE,QAAQ,iBAAiB,CAAC,IAC7H,EAAE,qCAAqC,mCAAmC,EAAE,QAAQ,iBAAiB,CAAC;AAC1G,QAAM,cAAc,eAChB,EAAE,oDAAoD,+EAA+E,IACrI,EAAE,uCAAuC,iFAAiF;AAC9H,QAAM,eAAe,eACjB,EAAE,iDAAiD,0BAA0B,IAC7E,EAAE,oCAAoC,mBAAmB;AAC7D,QAAM,gBAAgB,eAClB,MAAM;AAAE,oBAAgB;AAAG,eAAW;AAAA,EAAE,IACxC;AACJ,SACE,qBAAC,SAAI,WAAU,yDAAwD,eAAY,0BACjF;AAAA,wBAAC,SAAI,WAAU,kEACb,8BAAC,WAAQ,WAAU,gCAA+B,GACpD;AAAA,IACA,qBAAC,SAAI,WAAU,oCACb;AAAA,0BAAC,SAAI,WAAU,2BAA2B,iBAAM;AAAA,MAChD,oBAAC,SAAI,WAAU,sDAAsD,uBAAY;AAAA,OACnF;AAAA,IACA,qBAAC,SAAI,WAAU,2BACb;AAAA,0BAAC,UAAO,MAAK,UAAS,SAAS,eAAgB,wBAAa;AAAA,MAC5D,qBAAC,UAAO,MAAK,UAAS,SAAQ,WAAU,UAAU,CAAC,eAAe,SAAS,cACzE;AAAA,4BAAC,SAAM,WAAU,UAAS;AAAA,QACzB,EAAE,sCAAsC,oBAAoB;AAAA,SAC/D;AAAA,OACF;AAAA,KACF;AAEJ;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
+
import Link from "next/link";
|
|
4
|
+
import { Inbox, Plus } from "lucide-react";
|
|
5
|
+
import { Button } from "../../primitives/button.js";
|
|
6
|
+
import { EmptyState } from "../../primitives/empty-state.js";
|
|
7
|
+
import { useT } from "@open-mercato/shared/lib/i18n/context";
|
|
8
|
+
function ListEmptyState({
|
|
9
|
+
entityName,
|
|
10
|
+
title,
|
|
11
|
+
description,
|
|
12
|
+
createHref,
|
|
13
|
+
onCreate,
|
|
14
|
+
createLabel,
|
|
15
|
+
icon
|
|
16
|
+
}) {
|
|
17
|
+
const t = useT();
|
|
18
|
+
const entity = entityName ?? t("ui.dataTable.empty.genericEntity", "records");
|
|
19
|
+
const resolvedTitle = title ?? t("ui.dataTable.empty.title", "No {entity} yet", { entity });
|
|
20
|
+
const resolvedDescription = description ?? t("ui.dataTable.empty.description", "Items you add will show up here.");
|
|
21
|
+
const resolvedCreateLabel = createLabel ?? t("ui.dataTable.empty.create", "Create");
|
|
22
|
+
const action = createHref ? /* @__PURE__ */ jsx(Button, { asChild: true, children: /* @__PURE__ */ jsxs(Link, { href: createHref, children: [
|
|
23
|
+
/* @__PURE__ */ jsx(Plus, { className: "size-4", "aria-hidden": true }),
|
|
24
|
+
resolvedCreateLabel
|
|
25
|
+
] }) }) : onCreate ? /* @__PURE__ */ jsxs(Button, { type: "button", onClick: onCreate, children: [
|
|
26
|
+
/* @__PURE__ */ jsx(Plus, { className: "size-4", "aria-hidden": true }),
|
|
27
|
+
resolvedCreateLabel
|
|
28
|
+
] }) : null;
|
|
29
|
+
return /* @__PURE__ */ jsx(
|
|
30
|
+
EmptyState,
|
|
31
|
+
{
|
|
32
|
+
variant: "subtle",
|
|
33
|
+
size: "lg",
|
|
34
|
+
icon: icon ?? /* @__PURE__ */ jsx(Inbox, { className: "size-7", "aria-hidden": true }),
|
|
35
|
+
title: resolvedTitle,
|
|
36
|
+
description: resolvedDescription,
|
|
37
|
+
actions: action
|
|
38
|
+
}
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
export {
|
|
42
|
+
ListEmptyState
|
|
43
|
+
};
|
|
44
|
+
//# sourceMappingURL=ListEmptyState.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../src/backend/filters/ListEmptyState.tsx"],
|
|
4
|
+
"sourcesContent": ["'use client'\nimport * as React from 'react'\nimport Link from 'next/link'\nimport { Inbox, Plus } from 'lucide-react'\nimport { Button } from '../../primitives/button'\nimport { EmptyState } from '../../primitives/empty-state'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\n\nexport type ListEmptyStateProps = {\n /** Plural entity label, e.g. \"companies\". Used to build the default title. */\n entityName?: string\n /** Overrides the generated title. */\n title?: string\n /** Overrides the generated description. */\n description?: string\n /** Create link target; renders the primary \"create\" action. */\n createHref?: string\n /** Create click handler (alternative to createHref). */\n onCreate?: () => void\n /** Label for the create action (defaults to a generic \"Create\"). */\n createLabel?: string\n /** Optional leading icon (defaults to a neutral inbox glyph). */\n icon?: React.ReactNode\n}\n\n/**\n * Standardized zero-records empty state for list views (DataTable `emptyState`).\n * Use this when a list has no records yet (no active search/filter). For\n * no-results-after-search the DataTable renders SearchEmptyResults, and for\n * no-results-after-filter it renders FilteredEmptyResults.\n */\nexport function ListEmptyState({\n entityName,\n title,\n description,\n createHref,\n onCreate,\n createLabel,\n icon,\n}: ListEmptyStateProps) {\n const t = useT()\n const entity = entityName ?? t('ui.dataTable.empty.genericEntity', 'records')\n const resolvedTitle = title ?? t('ui.dataTable.empty.title', 'No {entity} yet', { entity })\n const resolvedDescription = description ?? t('ui.dataTable.empty.description', 'Items you add will show up here.')\n const resolvedCreateLabel = createLabel ?? t('ui.dataTable.empty.create', 'Create')\n const action = createHref ? (\n <Button asChild>\n <Link href={createHref}>\n <Plus className=\"size-4\" aria-hidden />\n {resolvedCreateLabel}\n </Link>\n </Button>\n ) : onCreate ? (\n <Button type=\"button\" onClick={onCreate}>\n <Plus className=\"size-4\" aria-hidden />\n {resolvedCreateLabel}\n </Button>\n ) : null\n return (\n <EmptyState\n variant=\"subtle\"\n size=\"lg\"\n icon={icon ?? <Inbox className=\"size-7\" aria-hidden />}\n title={resolvedTitle}\n description={resolvedDescription}\n actions={action}\n />\n )\n}\n"],
|
|
5
|
+
"mappings": ";AA+CM,SACE,KADF;AA7CN,OAAO,UAAU;AACjB,SAAS,OAAO,YAAY;AAC5B,SAAS,cAAc;AACvB,SAAS,kBAAkB;AAC3B,SAAS,YAAY;AAyBd,SAAS,eAAe;AAAA,EAC7B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,GAAwB;AACtB,QAAM,IAAI,KAAK;AACf,QAAM,SAAS,cAAc,EAAE,oCAAoC,SAAS;AAC5E,QAAM,gBAAgB,SAAS,EAAE,4BAA4B,mBAAmB,EAAE,OAAO,CAAC;AAC1F,QAAM,sBAAsB,eAAe,EAAE,kCAAkC,kCAAkC;AACjH,QAAM,sBAAsB,eAAe,EAAE,6BAA6B,QAAQ;AAClF,QAAM,SAAS,aACb,oBAAC,UAAO,SAAO,MACb,+BAAC,QAAK,MAAM,YACV;AAAA,wBAAC,QAAK,WAAU,UAAS,eAAW,MAAC;AAAA,IACpC;AAAA,KACH,GACF,IACE,WACF,qBAAC,UAAO,MAAK,UAAS,SAAS,UAC7B;AAAA,wBAAC,QAAK,WAAU,UAAS,eAAW,MAAC;AAAA,IACpC;AAAA,KACH,IACE;AACJ,SACE;AAAA,IAAC;AAAA;AAAA,MACC,SAAQ;AAAA,MACR,MAAK;AAAA,MACL,MAAM,QAAQ,oBAAC,SAAM,WAAU,UAAS,eAAW,MAAC;AAAA,MACpD,OAAO;AAAA,MACP,aAAa;AAAA,MACb,SAAS;AAAA;AAAA,EACX;AAEJ;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { SearchX, X } from "lucide-react";
|
|
4
|
+
import { Button } from "../../primitives/button.js";
|
|
5
|
+
import { useT } from "@open-mercato/shared/lib/i18n/context";
|
|
6
|
+
function SearchEmptyResults({ query, entityNamePlural, onClearSearch }) {
|
|
7
|
+
const t = useT();
|
|
8
|
+
const entity = entityNamePlural ?? t("ui.dataTable.search.empty.genericEntity", "results");
|
|
9
|
+
return /* @__PURE__ */ jsxs("div", { className: "flex w-full max-w-md flex-col items-center gap-4 py-10 text-center", "data-testid": "search-empty-results", children: [
|
|
10
|
+
/* @__PURE__ */ jsx("div", { className: "flex size-16 items-center justify-center rounded-full bg-muted", children: /* @__PURE__ */ jsx(SearchX, { className: "size-7 text-muted-foreground", "aria-hidden": true }) }),
|
|
11
|
+
/* @__PURE__ */ jsxs("div", { className: "flex flex-col items-center gap-1", children: [
|
|
12
|
+
/* @__PURE__ */ jsx("div", { className: "text-base font-semibold text-foreground", children: t("ui.dataTable.search.empty.title", "No results found") }),
|
|
13
|
+
/* @__PURE__ */ jsx("p", { className: "text-sm text-muted-foreground", children: t(
|
|
14
|
+
"ui.dataTable.search.empty.description",
|
|
15
|
+
"We couldn\u2019t find any {entity} matching \u201C{query}\u201D. Try adjusting your search or filters.",
|
|
16
|
+
{ entity, query }
|
|
17
|
+
) })
|
|
18
|
+
] }),
|
|
19
|
+
/* @__PURE__ */ jsxs(Button, { type: "button", onClick: onClearSearch, children: [
|
|
20
|
+
/* @__PURE__ */ jsx(X, { className: "size-4", "aria-hidden": true }),
|
|
21
|
+
t("ui.dataTable.search.empty.clear", "Clear search")
|
|
22
|
+
] })
|
|
23
|
+
] });
|
|
24
|
+
}
|
|
25
|
+
export {
|
|
26
|
+
SearchEmptyResults
|
|
27
|
+
};
|
|
28
|
+
//# sourceMappingURL=SearchEmptyResults.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../src/backend/filters/SearchEmptyResults.tsx"],
|
|
4
|
+
"sourcesContent": ["'use client'\nimport * as React from 'react'\nimport { SearchX, X } from 'lucide-react'\nimport { Button } from '../../primitives/button'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\n\nexport type SearchEmptyResultsProps = {\n /** The active search query, shown back to the user. */\n query: string\n /** Plural entity label (e.g. \"companies\"); falls back to a generic word. */\n entityNamePlural?: string\n /** Clears the active search. */\n onClearSearch: () => void\n}\n\nexport function SearchEmptyResults({ query, entityNamePlural, onClearSearch }: SearchEmptyResultsProps) {\n const t = useT()\n const entity = entityNamePlural ?? t('ui.dataTable.search.empty.genericEntity', 'results')\n return (\n <div className=\"flex w-full max-w-md flex-col items-center gap-4 py-10 text-center\" data-testid=\"search-empty-results\">\n <div className=\"flex size-16 items-center justify-center rounded-full bg-muted\">\n <SearchX className=\"size-7 text-muted-foreground\" aria-hidden />\n </div>\n <div className=\"flex flex-col items-center gap-1\">\n <div className=\"text-base font-semibold text-foreground\">\n {t('ui.dataTable.search.empty.title', 'No results found')}\n </div>\n <p className=\"text-sm text-muted-foreground\">\n {t(\n 'ui.dataTable.search.empty.description',\n 'We couldn\u2019t find any {entity} matching \u201C{query}\u201D. Try adjusting your search or filters.',\n { entity, query },\n )}\n </p>\n </div>\n <Button type=\"button\" onClick={onClearSearch}>\n <X className=\"size-4\" aria-hidden />\n {t('ui.dataTable.search.empty.clear', 'Clear search')}\n </Button>\n </div>\n )\n}\n"],
|
|
5
|
+
"mappings": ";AAqBQ,cAEF,YAFE;AAnBR,SAAS,SAAS,SAAS;AAC3B,SAAS,cAAc;AACvB,SAAS,YAAY;AAWd,SAAS,mBAAmB,EAAE,OAAO,kBAAkB,cAAc,GAA4B;AACtG,QAAM,IAAI,KAAK;AACf,QAAM,SAAS,oBAAoB,EAAE,2CAA2C,SAAS;AACzF,SACE,qBAAC,SAAI,WAAU,sEAAqE,eAAY,wBAC9F;AAAA,wBAAC,SAAI,WAAU,kEACb,8BAAC,WAAQ,WAAU,gCAA+B,eAAW,MAAC,GAChE;AAAA,IACA,qBAAC,SAAI,WAAU,oCACb;AAAA,0BAAC,SAAI,WAAU,2CACZ,YAAE,mCAAmC,kBAAkB,GAC1D;AAAA,MACA,oBAAC,OAAE,WAAU,iCACV;AAAA,QACC;AAAA,QACA;AAAA,QACA,EAAE,QAAQ,MAAM;AAAA,MAClB,GACF;AAAA,OACF;AAAA,IACA,qBAAC,UAAO,MAAK,UAAS,SAAS,eAC7B;AAAA,0BAAC,KAAE,WAAU,UAAS,eAAW,MAAC;AAAA,MACjC,EAAE,mCAAmC,cAAc;AAAA,OACtD;AAAA,KACF;AAEJ;",
|
|
6
|
+
"names": []
|
|
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.4790.1.bffb4fd44b",
|
|
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.4790.1.bffb4fd44b",
|
|
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.4790.1.bffb4fd44b",
|
|
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",
|
|
@@ -3,7 +3,7 @@ import * as React from 'react'
|
|
|
3
3
|
import { useRouter } from 'next/navigation'
|
|
4
4
|
import { useReactTable, getCoreRowModel, getSortedRowModel, flexRender, type ColumnDef, type SortingState, type Column as TableColumn, type VisibilityState, type RowSelectionState } from '@tanstack/react-table'
|
|
5
5
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|
6
|
-
import { RefreshCw, Loader2, SlidersHorizontal, MoreHorizontal, Circle, Filter, Columns3, ChevronUp, ChevronDown, ChevronsUpDown, Check } from 'lucide-react'
|
|
6
|
+
import { RefreshCw, Loader2, SlidersHorizontal, MoreHorizontal, Circle, Filter, Columns3, ChevronUp, ChevronDown, ChevronsUpDown, Check, Inbox } from 'lucide-react'
|
|
7
7
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../primitives/table'
|
|
8
8
|
import { Button } from '../primitives/button'
|
|
9
9
|
import { Checkbox } from '../primitives/checkbox'
|
|
@@ -22,6 +22,7 @@ import { TooltipProvider } from '../primitives/tooltip'
|
|
|
22
22
|
import { TruncatedCell } from './TruncatedCell'
|
|
23
23
|
import { FilterBar, type FilterDef, type FilterValues } from './FilterBar'
|
|
24
24
|
import { FilteredEmptyResults } from './filters/FilteredEmptyResults'
|
|
25
|
+
import { SearchEmptyResults } from './filters/SearchEmptyResults'
|
|
25
26
|
import { useCustomFieldFilterDefs } from './utils/customFieldFilters'
|
|
26
27
|
import { fetchCustomFieldDefinitionsPayload, type CustomFieldsetDto } from './utils/customFieldDefs'
|
|
27
28
|
import { RowActions, type RowActionItem } from './RowActions'
|
|
@@ -2552,6 +2553,22 @@ export function DataTable<T>({
|
|
|
2552
2553
|
const tableScrollWrapperClassName = embedded ? '' : 'overflow-auto'
|
|
2553
2554
|
|
|
2554
2555
|
const virtualScrollRef = React.useRef<HTMLDivElement>(null)
|
|
2556
|
+
// Measure the horizontal scroll viewport so the empty state can center within
|
|
2557
|
+
// the visible area instead of within the (often wider, overflowing) table.
|
|
2558
|
+
const [tableScrollEl, setTableScrollEl] = React.useState<HTMLDivElement | null>(null)
|
|
2559
|
+
const [emptyStateViewportWidth, setEmptyStateViewportWidth] = React.useState<number | null>(null)
|
|
2560
|
+
const setTableScrollWrapperRef = React.useCallback((node: HTMLDivElement | null) => {
|
|
2561
|
+
setTableScrollEl(node)
|
|
2562
|
+
virtualScrollRef.current = node
|
|
2563
|
+
}, [])
|
|
2564
|
+
React.useEffect(() => {
|
|
2565
|
+
if (!tableScrollEl || typeof ResizeObserver === 'undefined') return
|
|
2566
|
+
const update = () => setEmptyStateViewportWidth(tableScrollEl.clientWidth)
|
|
2567
|
+
update()
|
|
2568
|
+
const observer = new ResizeObserver(update)
|
|
2569
|
+
observer.observe(tableScrollEl)
|
|
2570
|
+
return () => observer.disconnect()
|
|
2571
|
+
}, [tableScrollEl])
|
|
2555
2572
|
const allRows = table.getRowModel().rows
|
|
2556
2573
|
const rowVirtualizer = virtualized
|
|
2557
2574
|
? useVirtualizer({
|
|
@@ -2668,7 +2685,7 @@ export function DataTable<T>({
|
|
|
2668
2685
|
columnIds={headerColumnIds}
|
|
2669
2686
|
onDragEnd={handleHeaderDragEnd}
|
|
2670
2687
|
>
|
|
2671
|
-
<div ref={
|
|
2688
|
+
<div ref={setTableScrollWrapperRef} className={tableScrollWrapperClassName} style={virtualMaxHeightStyle}>
|
|
2672
2689
|
<Table className="min-w-[640px] md:min-w-0">
|
|
2673
2690
|
<TableHeader>
|
|
2674
2691
|
{table.getHeaderGroups().map((hg) => (
|
|
@@ -2871,26 +2888,44 @@ export function DataTable<T>({
|
|
|
2871
2888
|
</>
|
|
2872
2889
|
) : (
|
|
2873
2890
|
<TableRow>
|
|
2874
|
-
<TableCell colSpan={mergedColumns.length + (rowActions || injectedRowActions.length > 0 ? 1 : 0) + (hasInjectedBulkActions ? 1 : 0)} className="
|
|
2875
|
-
|
|
2876
|
-
|
|
2877
|
-
|
|
2878
|
-
|
|
2879
|
-
|
|
2880
|
-
|
|
2881
|
-
|
|
2882
|
-
|
|
2883
|
-
|
|
2884
|
-
|
|
2885
|
-
|
|
2886
|
-
|
|
2887
|
-
|
|
2888
|
-
|
|
2889
|
-
|
|
2890
|
-
|
|
2891
|
-
|
|
2892
|
-
|
|
2893
|
-
|
|
2891
|
+
<TableCell colSpan={mergedColumns.length + (rowActions || injectedRowActions.length > 0 ? 1 : 0) + (hasInjectedBulkActions ? 1 : 0)} className="p-0">
|
|
2892
|
+
<div
|
|
2893
|
+
className={cn('sticky left-0 flex justify-center py-6', emptyStateViewportWidth ? '' : 'w-fit')}
|
|
2894
|
+
style={emptyStateViewportWidth ? { width: emptyStateViewportWidth } : undefined}
|
|
2895
|
+
>
|
|
2896
|
+
{filterAwareEmptyState?.active ? (
|
|
2897
|
+
<FilteredEmptyResults
|
|
2898
|
+
entityNamePlural={filterAwareEmptyState.entityNamePlural}
|
|
2899
|
+
canRemoveLast={filterAwareEmptyState.canRemoveLast}
|
|
2900
|
+
onClearAll={filterAwareEmptyState.onClearAll}
|
|
2901
|
+
onRemoveLast={filterAwareEmptyState.onRemoveLast}
|
|
2902
|
+
onClearSearch={searchValue && searchValue.trim().length > 0 && onSearchChange ? () => onSearchChange('') : undefined}
|
|
2903
|
+
/>
|
|
2904
|
+
) : searchValue && searchValue.trim().length > 0 && onSearchChange ? (
|
|
2905
|
+
<SearchEmptyResults
|
|
2906
|
+
query={searchValue.trim()}
|
|
2907
|
+
entityNamePlural={filterAwareEmptyState?.entityNamePlural}
|
|
2908
|
+
onClearSearch={() => onSearchChange('')}
|
|
2909
|
+
/>
|
|
2910
|
+
) : emptyState && typeof emptyState !== 'string' ? (
|
|
2911
|
+
emptyState
|
|
2912
|
+
) : (
|
|
2913
|
+
<EmptyState
|
|
2914
|
+
variant="subtle"
|
|
2915
|
+
icon={<Inbox className="size-6" aria-hidden />}
|
|
2916
|
+
title={
|
|
2917
|
+
typeof emptyState === 'string'
|
|
2918
|
+
? emptyState
|
|
2919
|
+
: t('ui.dataTable.emptyState.default', 'No results.')
|
|
2920
|
+
}
|
|
2921
|
+
description={
|
|
2922
|
+
typeof emptyState === 'string'
|
|
2923
|
+
? undefined
|
|
2924
|
+
: t('ui.dataTable.emptyState.defaultDescription', 'Items will appear here once added.')
|
|
2925
|
+
}
|
|
2926
|
+
/>
|
|
2927
|
+
)}
|
|
2928
|
+
</div>
|
|
2894
2929
|
</TableCell>
|
|
2895
2930
|
</TableRow>
|
|
2896
2931
|
)}
|
|
@@ -9,27 +9,40 @@ export type FilteredEmptyResultsProps = {
|
|
|
9
9
|
canRemoveLast: boolean
|
|
10
10
|
onClearAll: () => void
|
|
11
11
|
onRemoveLast: () => void
|
|
12
|
+
/**
|
|
13
|
+
* When provided, a search is ALSO active alongside the filters. Switches the
|
|
14
|
+
* copy to a combined search+filter message and makes the primary action clear
|
|
15
|
+
* both the search and the filters (so a single click shows everyone).
|
|
16
|
+
*/
|
|
17
|
+
onClearSearch?: () => void
|
|
12
18
|
}
|
|
13
19
|
|
|
14
|
-
export function FilteredEmptyResults({ entityNamePlural, canRemoveLast, onClearAll, onRemoveLast }: FilteredEmptyResultsProps) {
|
|
20
|
+
export function FilteredEmptyResults({ entityNamePlural, canRemoveLast, onClearAll, onRemoveLast, onClearSearch }: FilteredEmptyResultsProps) {
|
|
15
21
|
const t = useT()
|
|
22
|
+
const searchActive = typeof onClearSearch === 'function'
|
|
23
|
+
const title = searchActive
|
|
24
|
+
? t('ui.advancedFilter.empty.noMatchesSearchFilters', 'No {entity} match your search and filters', { entity: entityNamePlural })
|
|
25
|
+
: t('ui.advancedFilter.empty.noMatches', 'No {entity} match these filters', { entity: entityNamePlural })
|
|
26
|
+
const description = searchActive
|
|
27
|
+
? t('ui.advancedFilter.empty.tryRemovingSearchFilters', 'Try a different search, remove a filter, or clear everything to see everyone.')
|
|
28
|
+
: t('ui.advancedFilter.empty.tryRemoving', 'Try removing the most restrictive filter, or clear all filters to see everyone.')
|
|
29
|
+
const primaryLabel = searchActive
|
|
30
|
+
? t('ui.advancedFilter.empty.clearSearchAndFilters', 'Clear search and filters')
|
|
31
|
+
: t('ui.advancedFilter.empty.clearAll', 'Clear all filters')
|
|
32
|
+
const handlePrimary = searchActive
|
|
33
|
+
? () => { onClearSearch?.(); onClearAll() }
|
|
34
|
+
: onClearAll
|
|
16
35
|
return (
|
|
17
36
|
<div className="flex flex-col items-center justify-center gap-4 py-12" data-testid="filtered-empty-results">
|
|
18
37
|
<div className="flex size-14 items-center justify-center rounded-full bg-muted">
|
|
19
38
|
<SearchX className="size-6 text-muted-foreground" />
|
|
20
39
|
</div>
|
|
21
40
|
<div className="flex flex-col items-center gap-1">
|
|
22
|
-
<div className="text-base font-semibold">
|
|
23
|
-
|
|
24
|
-
</div>
|
|
25
|
-
<div className="text-sm text-muted-foreground max-w-md text-center">
|
|
26
|
-
{t('ui.advancedFilter.empty.tryRemoving', 'Try removing the most restrictive filter, or clear all filters to see everyone.')}
|
|
27
|
-
</div>
|
|
41
|
+
<div className="text-base font-semibold">{title}</div>
|
|
42
|
+
<div className="text-sm text-muted-foreground max-w-md text-center">{description}</div>
|
|
28
43
|
</div>
|
|
29
44
|
<div className="flex items-center gap-2">
|
|
30
|
-
<Button type="button" onClick={
|
|
31
|
-
{t('ui.advancedFilter.empty.clearAll', 'Clear all filters')}
|
|
32
|
-
</Button>
|
|
45
|
+
<Button type="button" onClick={handlePrimary}>{primaryLabel}</Button>
|
|
33
46
|
<Button type="button" variant="outline" disabled={!canRemoveLast} onClick={onRemoveLast}>
|
|
34
47
|
<Undo2 className="size-4" />
|
|
35
48
|
{t('ui.advancedFilter.empty.removeLast', 'Remove last filter')}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
import * as React from 'react'
|
|
3
|
+
import Link from 'next/link'
|
|
4
|
+
import { Inbox, Plus } from 'lucide-react'
|
|
5
|
+
import { Button } from '../../primitives/button'
|
|
6
|
+
import { EmptyState } from '../../primitives/empty-state'
|
|
7
|
+
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
8
|
+
|
|
9
|
+
export type ListEmptyStateProps = {
|
|
10
|
+
/** Plural entity label, e.g. "companies". Used to build the default title. */
|
|
11
|
+
entityName?: string
|
|
12
|
+
/** Overrides the generated title. */
|
|
13
|
+
title?: string
|
|
14
|
+
/** Overrides the generated description. */
|
|
15
|
+
description?: string
|
|
16
|
+
/** Create link target; renders the primary "create" action. */
|
|
17
|
+
createHref?: string
|
|
18
|
+
/** Create click handler (alternative to createHref). */
|
|
19
|
+
onCreate?: () => void
|
|
20
|
+
/** Label for the create action (defaults to a generic "Create"). */
|
|
21
|
+
createLabel?: string
|
|
22
|
+
/** Optional leading icon (defaults to a neutral inbox glyph). */
|
|
23
|
+
icon?: React.ReactNode
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Standardized zero-records empty state for list views (DataTable `emptyState`).
|
|
28
|
+
* Use this when a list has no records yet (no active search/filter). For
|
|
29
|
+
* no-results-after-search the DataTable renders SearchEmptyResults, and for
|
|
30
|
+
* no-results-after-filter it renders FilteredEmptyResults.
|
|
31
|
+
*/
|
|
32
|
+
export function ListEmptyState({
|
|
33
|
+
entityName,
|
|
34
|
+
title,
|
|
35
|
+
description,
|
|
36
|
+
createHref,
|
|
37
|
+
onCreate,
|
|
38
|
+
createLabel,
|
|
39
|
+
icon,
|
|
40
|
+
}: ListEmptyStateProps) {
|
|
41
|
+
const t = useT()
|
|
42
|
+
const entity = entityName ?? t('ui.dataTable.empty.genericEntity', 'records')
|
|
43
|
+
const resolvedTitle = title ?? t('ui.dataTable.empty.title', 'No {entity} yet', { entity })
|
|
44
|
+
const resolvedDescription = description ?? t('ui.dataTable.empty.description', 'Items you add will show up here.')
|
|
45
|
+
const resolvedCreateLabel = createLabel ?? t('ui.dataTable.empty.create', 'Create')
|
|
46
|
+
const action = createHref ? (
|
|
47
|
+
<Button asChild>
|
|
48
|
+
<Link href={createHref}>
|
|
49
|
+
<Plus className="size-4" aria-hidden />
|
|
50
|
+
{resolvedCreateLabel}
|
|
51
|
+
</Link>
|
|
52
|
+
</Button>
|
|
53
|
+
) : onCreate ? (
|
|
54
|
+
<Button type="button" onClick={onCreate}>
|
|
55
|
+
<Plus className="size-4" aria-hidden />
|
|
56
|
+
{resolvedCreateLabel}
|
|
57
|
+
</Button>
|
|
58
|
+
) : null
|
|
59
|
+
return (
|
|
60
|
+
<EmptyState
|
|
61
|
+
variant="subtle"
|
|
62
|
+
size="lg"
|
|
63
|
+
icon={icon ?? <Inbox className="size-7" aria-hidden />}
|
|
64
|
+
title={resolvedTitle}
|
|
65
|
+
description={resolvedDescription}
|
|
66
|
+
actions={action}
|
|
67
|
+
/>
|
|
68
|
+
)
|
|
69
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
import * as React from 'react'
|
|
3
|
+
import { SearchX, X } from 'lucide-react'
|
|
4
|
+
import { Button } from '../../primitives/button'
|
|
5
|
+
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
6
|
+
|
|
7
|
+
export type SearchEmptyResultsProps = {
|
|
8
|
+
/** The active search query, shown back to the user. */
|
|
9
|
+
query: string
|
|
10
|
+
/** Plural entity label (e.g. "companies"); falls back to a generic word. */
|
|
11
|
+
entityNamePlural?: string
|
|
12
|
+
/** Clears the active search. */
|
|
13
|
+
onClearSearch: () => void
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function SearchEmptyResults({ query, entityNamePlural, onClearSearch }: SearchEmptyResultsProps) {
|
|
17
|
+
const t = useT()
|
|
18
|
+
const entity = entityNamePlural ?? t('ui.dataTable.search.empty.genericEntity', 'results')
|
|
19
|
+
return (
|
|
20
|
+
<div className="flex w-full max-w-md flex-col items-center gap-4 py-10 text-center" data-testid="search-empty-results">
|
|
21
|
+
<div className="flex size-16 items-center justify-center rounded-full bg-muted">
|
|
22
|
+
<SearchX className="size-7 text-muted-foreground" aria-hidden />
|
|
23
|
+
</div>
|
|
24
|
+
<div className="flex flex-col items-center gap-1">
|
|
25
|
+
<div className="text-base font-semibold text-foreground">
|
|
26
|
+
{t('ui.dataTable.search.empty.title', 'No results found')}
|
|
27
|
+
</div>
|
|
28
|
+
<p className="text-sm text-muted-foreground">
|
|
29
|
+
{t(
|
|
30
|
+
'ui.dataTable.search.empty.description',
|
|
31
|
+
'We couldn’t find any {entity} matching “{query}”. Try adjusting your search or filters.',
|
|
32
|
+
{ entity, query },
|
|
33
|
+
)}
|
|
34
|
+
</p>
|
|
35
|
+
</div>
|
|
36
|
+
<Button type="button" onClick={onClearSearch}>
|
|
37
|
+
<X className="size-4" aria-hidden />
|
|
38
|
+
{t('ui.dataTable.search.empty.clear', 'Clear search')}
|
|
39
|
+
</Button>
|
|
40
|
+
</div>
|
|
41
|
+
)
|
|
42
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import * as React from 'react'
|
|
6
|
+
import { render, screen } from '@testing-library/react'
|
|
7
|
+
import { ListEmptyState } from '../ListEmptyState'
|
|
8
|
+
|
|
9
|
+
jest.mock('@open-mercato/shared/lib/i18n/context', () => ({
|
|
10
|
+
useT: () => (_key: string, fallback?: string, params?: Record<string, unknown>) => {
|
|
11
|
+
let out = fallback ?? _key
|
|
12
|
+
if (params) for (const k of Object.keys(params)) out = out.replace(`{${k}}`, String(params[k]))
|
|
13
|
+
return out
|
|
14
|
+
},
|
|
15
|
+
}))
|
|
16
|
+
|
|
17
|
+
jest.mock('next/link', () => {
|
|
18
|
+
const React = require('react')
|
|
19
|
+
return React.forwardRef(
|
|
20
|
+
({ children, href, ...rest }: any, ref: React.ForwardedRef<HTMLAnchorElement>) => (
|
|
21
|
+
<a href={typeof href === 'string' ? href : href?.toString?.()} ref={ref} {...rest}>
|
|
22
|
+
{children}
|
|
23
|
+
</a>
|
|
24
|
+
),
|
|
25
|
+
)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
describe('ListEmptyState', () => {
|
|
29
|
+
it('builds the title from entityName', () => {
|
|
30
|
+
render(<ListEmptyState entityName="companies" />)
|
|
31
|
+
expect(screen.getByText('No companies yet')).toBeInTheDocument()
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('renders a create link (role=link) with href and label when createHref is set', () => {
|
|
35
|
+
render(<ListEmptyState entityName="companies" createHref="/backend/customers/companies/create" createLabel="New company" />)
|
|
36
|
+
const link = screen.getByRole('link', { name: /new company/i })
|
|
37
|
+
expect(link).toBeInTheDocument()
|
|
38
|
+
expect(link).toHaveAttribute('href', '/backend/customers/companies/create')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('renders a create button when onCreate is provided', () => {
|
|
42
|
+
const onCreate = jest.fn()
|
|
43
|
+
render(<ListEmptyState entityName="users" onCreate={onCreate} createLabel="Add user" />)
|
|
44
|
+
expect(screen.getByRole('button', { name: /add user/i })).toBeInTheDocument()
|
|
45
|
+
expect(screen.queryByRole('link')).not.toBeInTheDocument()
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('renders no action when neither createHref nor onCreate is provided', () => {
|
|
49
|
+
render(<ListEmptyState entityName="records" />)
|
|
50
|
+
expect(screen.queryByRole('link')).not.toBeInTheDocument()
|
|
51
|
+
expect(screen.queryByRole('button')).not.toBeInTheDocument()
|
|
52
|
+
})
|
|
53
|
+
})
|