Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 58 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,62 @@ Versioning follows the policy in [CONTRIBUTING.md](CONTRIBUTING.md#versioning).

## [Unreleased]

## [0.1.0-alpha.6]

Four places where one product's concepts had come across with the code, found
by re-reading the package against a single question: is this a rendering
component, or does it need to know something only a product knows. All four
are breaking, and they land while there is exactly one consumer.

### Changed

- **`DataTable` no longer writes to `localStorage`.** `persistKey` is replaced
by `columnState` and `onColumnStateChange`. Where column preferences live,
under which key, per user or per workspace, or whether they persist at all,
is a decision only the consumer can make, and a component that answers it
cannot be reused by a consumer that answers differently. It also stopped the
table working anywhere `localStorage` is absent.
- **`ErrorState` takes a tone, not an error category.** `type` was
`"network" | "configuration" | "model" | "permission" | "generic"`; `"model"`
in particular is one product's vocabulary. All five resolved to three colours
anyway, so the prop is now `tone: "danger" | "warning" | "accent"`, plus an
`icon` slot. A consumer maps its own categories onto tones.
- **`EmptyState` takes an illustration, not the name of one.** The ten drawings
that shipped here (chat, models, creations, benchmark, logs, statistics,
schedule and the rest) are one product's information architecture; no other
consumer has a "creations" screen to draw for. `illustration` is now a
`ReactNode`, and the drawings move to the product that owns those screens.
This reverses the export added in 0.1.0-alpha.3, which unblocked a consumer
by widening the wrong side of the boundary.
- **`Tabs` no longer carries a guide-tag system.** `tag`, the deprecated
`required`, `TabTagType`, `TabTagLabels`, `TAG_CONFIG` and `tagLabels` are
removed. The arrangement had already split across the boundary, with the
badge variant here and the label text passed in from the consumer's locale
bundle, which is what a wrong boundary looks like. A consumer renders its own
badge through the existing `TabItem.labelExtra` slot and owns both halves;
the `.tabs__tag-badge` class stays for the styling.

### Migration

```tsx
// DataTable
- <DataTable persistKey="sessions.activeTab" ... />
+ <DataTable columnState={state} onColumnStateChange={setState} ... />

// ErrorState
- <ErrorState type="model" ... /> → tone="danger"
- <ErrorState type="network" ... /> → tone="warning"
- <ErrorState type="configuration" ... />→ tone="accent"

// EmptyState
- <EmptyState illustration="models" ... />
+ <EmptyState illustration={<ModelsIllustration />} ... />

// Tabs
- { id, label, content, tag: "beta" }
+ { id, label, content, labelExtra: <Badge variant="info">Beta</Badge> }
```

## [0.1.0-alpha.5]

### Fixed
Expand Down Expand Up @@ -83,7 +139,8 @@ mid-migration.
validation, and a clean external React install fixture.
- Apache-2.0 license and the initial public boundary rules.

[Unreleased]: https://github.com/lablup/ui-common/compare/v0.1.0-alpha.5...HEAD
[Unreleased]: https://github.com/lablup/ui-common/compare/v0.1.0-alpha.6...HEAD
[0.1.0-alpha.6]: https://github.com/lablup/ui-common/compare/v0.1.0-alpha.5...v0.1.0-alpha.6
[0.1.0-alpha.5]: https://github.com/lablup/ui-common/compare/v0.1.0-alpha.4...v0.1.0-alpha.5
[0.1.0-alpha.4]: https://github.com/lablup/ui-common/compare/v0.1.0-alpha.3...v0.1.0-alpha.4
[0.1.0-alpha.3]: https://github.com/lablup/ui-common/compare/v0.1.0-alpha.2...v0.1.0-alpha.3
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@lablup/ui-common",
"version": "0.1.0-alpha.5",
"version": "0.1.0-alpha.6",
"description": "Shared, product-neutral UI components and design tokens for Lablup products",
"license": "Apache-2.0",
"author": "Lablup Inc.",
Expand Down
67 changes: 53 additions & 14 deletions src/components/DataTable/DataTable.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -135,31 +135,20 @@ describe("DataTable", () => {
expect(onClick).toHaveBeenCalledWith({ id: "a", name: "Alpha", score: 3 });
});

it("loads persisted widths from localStorage on mount", () => {
window.localStorage.setItem(
"dataTable.test-key",
JSON.stringify({ widths: { name: 333 }, visibility: {} }),
);
it("applies the column widths it is given", () => {
const { container } = render(
<DataTable
columns={COLUMNS}
rows={ROWS}
getRowKey={(r) => r.id}
persistKey="test-key"
columnState={{ widths: { name: 333 }, visibility: {} }}
/>,
);
const headers = container.querySelectorAll("th");
expect(headers[0]?.getAttribute("style")).toContain("width: 333px");
});

it("hides columns whose visibility flag is false unless alwaysVisible", () => {
window.localStorage.setItem(
"dataTable.test-key-2",
JSON.stringify({
widths: {},
visibility: { name: false, id: false },
}),
);
const cols: DataTableColumn<Row>[] = [
{ ...COLUMNS[0]!, alwaysVisible: true },
{ ...COLUMNS[1]! },
Expand All @@ -169,13 +158,63 @@ describe("DataTable", () => {
columns={cols}
rows={ROWS}
getRowKey={(r) => r.id}
persistKey="test-key-2"
columnState={{ widths: {}, visibility: { name: false, id: false } }}
/>,
);
expect(screen.getByText("Name")).toBeInTheDocument();
expect(screen.queryByText("ID")).not.toBeInTheDocument();
});

it("touches no storage of its own", () => {
const setItem = vi.spyOn(Storage.prototype, "setItem");
const getItem = vi.spyOn(Storage.prototype, "getItem");

render(
<DataTable
columns={COLUMNS}
rows={ROWS}
getRowKey={(r) => r.id}
columnState={{ widths: { name: 200 }, visibility: {} }}
onColumnStateChange={() => {}}
/>,
);

// Where column preferences live is the consumer's decision, and a host
// without `localStorage` has to keep working.
expect(setItem).not.toHaveBeenCalled();
expect(getItem).not.toHaveBeenCalled();
setItem.mockRestore();
getItem.mockRestore();
});

it("does not report the state it was handed back to the caller", () => {
const onColumnStateChange = vi.fn();
const columnState = { widths: { name: 250 }, visibility: {} };

const { rerender } = render(
<DataTable
columns={COLUMNS}
rows={ROWS}
getRowKey={(r) => r.id}
columnState={columnState}
onColumnStateChange={onColumnStateChange}
/>,
);
rerender(
<DataTable
columns={COLUMNS}
rows={ROWS}
getRowKey={(r) => r.id}
columnState={columnState}
onColumnStateChange={onColumnStateChange}
/>,
);

// A caller that persists on change and feeds the result back must not
// find itself in a loop.
expect(onColumnStateChange).not.toHaveBeenCalled();
});

// ---- Sorting: aria-sort -------------------------------------------------

it("renders aria-sort='none' on sortable columns when no sort is active", () => {
Expand Down
101 changes: 34 additions & 67 deletions src/components/DataTable/DataTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -143,11 +143,22 @@ export interface DataTableProps<T> {
/** When `true`, the loading slot replaces the table body. */
loading?: boolean;
/**
* When provided, column widths and visibility settings are persisted
* to `localStorage` under this key. Use a stable, namespaced string
* (e.g. `"sessions.activeTab"`).
* Column widths and visibility, controlled by the caller.
*
* This component used to take a `persistKey` and write to `localStorage`
* itself. Storage is a policy decision that belongs to the consumer: where
* it goes, under which key, whether it is per user or per workspace, and
* whether it exists at all in a host that has no `localStorage`. A
* rendering component that answers those questions on its own cannot be
* reused by a consumer that answers them differently.
*/
columnState?: DataTablePersistedState;
/**
* Called whenever the user resizes a column or toggles its visibility.
* Pair it with `columnState` to persist wherever the consumer keeps
* preferences; omit both to get a table that forgets on unmount.
*/
persistKey?: string;
onColumnStateChange?: (state: DataTablePersistedState) => void;
/** Extra class for the table's wrapping element. */
className?: string;
/** ARIA label for the table. Defaults to "Data table". */
Expand Down Expand Up @@ -190,58 +201,7 @@ export interface DataTableProps<T> {
) => void;
}

// ============================================================================
// Storage helpers
// ============================================================================

const STORAGE_NAMESPACE = "dataTable";

function buildStorageKey(persistKey: string): string {
return `${STORAGE_NAMESPACE}.${persistKey}`;
}

/**
* Load persisted state from `localStorage`. Returns an empty state on
* any failure (missing storage, malformed JSON, schema drift) so the
* component degrades gracefully.
*/
function loadPersistedState(persistKey: string | undefined): DataTablePersistedState {
if (!persistKey || typeof window === "undefined") {
return { widths: {}, visibility: {} };
}
try {
const raw = window.localStorage.getItem(buildStorageKey(persistKey));
if (!raw) return { widths: {}, visibility: {} };
const parsed = JSON.parse(raw) as unknown;
if (typeof parsed !== "object" || parsed === null) {
return { widths: {}, visibility: {} };
}
const obj = parsed as { widths?: unknown; visibility?: unknown };
const widths =
typeof obj.widths === "object" && obj.widths !== null
? (obj.widths as Record<string, number>)
: {};
const visibility =
typeof obj.visibility === "object" && obj.visibility !== null
? (obj.visibility as Record<string, boolean>)
: {};
return { widths, visibility };
} catch {
return { widths: {}, visibility: {} };
}
}

function savePersistedState(
persistKey: string | undefined,
state: DataTablePersistedState,
): void {
if (!persistKey || typeof window === "undefined") return;
try {
window.localStorage.setItem(buildStorageKey(persistKey), JSON.stringify(state));
} catch {
// Quota exceeded / disabled storage — silently ignore.
}
}
const EMPTY_COLUMN_STATE: DataTablePersistedState = { widths: {}, visibility: {} };

// ============================================================================
// Sorting helpers
Expand Down Expand Up @@ -389,7 +349,8 @@ function DataTableInner<T>({
emptyState,
loadingState,
loading = false,
persistKey,
columnState,
onColumnStateChange,
className = "",
ariaLabel = "Data table",
testId,
Expand All @@ -399,16 +360,17 @@ function DataTableInner<T>({
sortDirection: controlledSortDirection,
onSortChange,
}: DataTableProps<T>) {
// ---- Persisted state (widths + visibility) -------------------------------
const [persisted, setPersisted] = useState<DataTablePersistedState>(() =>
loadPersistedState(persistKey),
// ---- Column state (widths + visibility) ----------------------------------
//
// Held internally so a caller that does not care about persistence gets a
// working table, and re-seeded whenever the caller supplies a new one.
const [persisted, setPersisted] = useState<DataTablePersistedState>(
() => columnState ?? EMPTY_COLUMN_STATE,
);

// Refresh persisted state when the storage key changes (defensive — the
// expected use case is a stable key).
useEffect(() => {
setPersisted(loadPersistedState(persistKey));
}, [persistKey]);
if (columnState) setPersisted(columnState);
}, [columnState]);

// ---- Sorting state (uncontrolled fallback) -------------------------------
//
Expand Down Expand Up @@ -537,10 +499,15 @@ function DataTableInner<T>({
[],
);

// Persist when state changes
// Report changes so the caller can persist them. Skipped while `persisted`
// still holds what the caller last handed in, so echoing the callback back
// through `columnState` does not loop.
const reported = useRef(persisted);
useEffect(() => {
savePersistedState(persistKey, persisted);
}, [persistKey, persisted]);
if (reported.current === persisted) return;
reported.current = persisted;
onColumnStateChange?.(persisted);
}, [persisted, onColumnStateChange]);

// ---- Sorted rows --------------------------------------------------------
const sortedRows = useMemo(() => {
Expand Down
Loading