import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { RuleTester } from 'eslint'
import { parser } from 'typescript-eslint'
import { describe, it } from 'vitest'
import { noHandmadeEmptyState } from './no-handmade-empty-state'
RuleTester.describe = describe
RuleTester.it = it
RuleTester.itOnly = it.only
const tester = new RuleTester({
languageOptions: { parser, parserOptions: { ecmaFeatures: { jsx: true } } },
linterOptions: { reportUnusedDisableDirectives: 'off' },
})
/**
* The wrong-scope half follows the app's components into their files, so its
* cases live in a small app on disk, laid out the way Peek's Folders page was at
* `7f22e5e~1`: the page scrolls a view drawn in another file.
*/
const root = mkdtempSync(join(tmpdir(), 'no-handmade-empty-state-'))
const write = (path: string, text: string) => {
mkdirSync(dirname(join(root, path)), { recursive: true })
writeFileSync(join(root, path), text)
}
write('app/package.json', '{ "name": "app" }')
write(
'app/src/components/ui/EmptyState.tsx',
"import { EmptyState as UiEmptyState, type EmptyStateProps } from '@estiva-app/ui'\nexport function EmptyState({ message = 'Nothing', ...rest }: EmptyStateProps) {\n return \n}\n",
)
// The file conversation, as it arrived in the merge: two page states in a view another file scrolls.
write(
'app/src/components/views/ForeignConversationView.tsx',
"import { EmptyState } from '@/components/ui/EmptyState'\nexport function ForeignConversationView({ state }: { state: string[] | 'failed' }) {\n if (state === 'failed') return \n if (state.length === 0) return \n return
{state.map((c) =>
{c}
)}
\n}\n",
)
// The folder view: its own states are sections; the rows it draws hold a page state two components down.
write(
'app/src/components/views/FolderContentsView.tsx',
"import { EmptyState } from '@/components/ui/EmptyState'\nexport function FolderContentsView({ files }: { files: string[] }) {\n if (files.length === 0) return \n return
\n}\nfunction ChildRows({ file }: { file: string }) {\n const children: string[] = file ? [] : [file]\n if (children.length === 0) return \n return <>{children.map((c) => )}>\n}\n",
)
// Ship's Activity: it hands its className on to a section EmptyState. It is the app's own, and is followed.
write(
'app/src/components/Activity.tsx',
"import { EmptyState } from '@estiva-app/ui'\nexport function Activity({ items, className }: { items: string[]; className?: string }) {\n if (items.length === 0) return \n return
{items.map((i) =>
{i}
)}
\n}\n",
)
const app = join(root, 'app/src/pages/Probe.tsx')
const component = (body: string) => `export function Probe({ rows, report, answer, choosing }: { rows: string[]; report?: string; answer: false | { text: string; sources: string[] }; choosing: boolean }) {\n${body}\n}\n`
const jsx = (body: string) => component(` return (\n${body}\n )`)
const scrolled = (imports: string, body: string) =>
`import { ScrollArea, EmptyState } from '@estiva-app/ui'\n${imports}\nexport function Probe({ rows }: { rows: string[] }) {\n return (\n${body}\n )\n}\n`
const handmade = [{ messageId: 'handmade' }]
/** A component that draws `body`, with the app's own EmptyState imported. */
const pageIn = (body: string) => `import { EmptyState } from '@/components/ui/EmptyState'\nexport function Probe() {\n return (\n${body}\n )\n}\n`
tester.run('no-handmade-empty-state', noHandmadeEmptyState, {
valid: [
// the part itself
{ name: 'the part, in place of the list', code: jsx('
{rows.length === 0 ? : rows.map((r) =>
{r}
)}
') },
// UIG-1's two false alarms, which the ticket makes regression tests
{
name: '"Nothing is created until you submit it." — a hint beside a button (CommandLauncher.tsx:1498)',
code: jsx('<>\n\nNothing is created until you submit it.\n>'),
},
{
name: '"…this folder has no folder state yet." — a status note, under a condition that asks nothing about a list (FolderContentsView.tsx:101)',
code: component(' const folder = { source: "channel" }\n return (\n
\n{folder.source === "channel" && (\n
From channel contents; this folder has no folder state yet.
\n)}\n{rows.map((r) =>
{r}
)}\n
\n )'),
},
// the ticket's three ❓, ruled not empty states (GATES.md)
{
name: '"No comparison yet — it needs one relay fetch." — a debug line for a report not fetched yet (ReadStatePanel.tsx:65)',
code: component(' if (!report) return
No comparison yet — it needs one relay fetch.
\n return
{report}
'),
},
{
name: '"No answer came back." — a failure after a request; the other side is an answer, with its sources listed inside it (CommandLauncher.tsx:1514)',
code: jsx('
{answer === false ? (\n
No answer came back. Edit the question and press Enter to try again.
\n) : (\n<>\n
{answer.text}
\n{answer.sources.map((s) =>
{s}
)}\n>\n)}
'),
},
// look-alikes
{ name: 'the full side of the test: a count', code: jsx('
{rows.length > 0 && {rows.length}}
') },
{ name: 'the full side of the test, with words', code: jsx('
{rows.length > 0 &&
Recent
}{rows.map((r) =>
{r}
)}
') },
{ name: 'the full side of a ternary', code: jsx('
{rows.length ?
Pick one below.
: }
') },
{
name: 'a status line whose words are a choice (ReactionPicker.stories.tsx:76)',
code: component(' const chosen = rows[0]\n return {chosen ? `Chose ${chosen}` : "Nothing chosen yet."}'),
},
{ name: 'a paragraph, not a line', code: jsx(`
{rows.length === 0 ?
${'Nothing here, and a long explanation of why that is and what to do next about it. '.repeat(2)}
: rows.map((r) =>
{r}
)}
`) },
{ name: 'a line that is a value, not words: a message body', code: jsx('
{rows.length === 0 ?
{report}
: rows.map((r) =>
{r}
)}
') },
{ name: 'a box holding more than the line', code: jsx('
{rows.length === 0 ?
Nothing yet.
: rows.map((r) =>
{r}
)}
') },
{
name: 'an escape with its reason',
code: jsx('
{rows.length === 0 ? (\n// @estiva-escape(no-handmade-empty-state): a printed report keeps its own empty line, set in the print style\n
Nothing to print.
\n) : rows.map((r) =>
{r}
)}
'),
},
// the right scope
{ name: 'a page state for a whole column, outside any ScrollArea', filename: app, code: scrolled('', '
{rows.length === 0 ? : {rows.map((r) =>
{r}
)}}
') },
{ name: 'a section state inside a ScrollArea', filename: app, code: scrolled('', '{rows.length === 0 ? : null}') },
{ name: 'a page state inside a ScrollArea whose content fills it', filename: app, code: scrolled('', '{rows.length === 0 && }') },
{ name: 'a scope that is decided at run time is not judged', filename: app, code: scrolled('', '') },
{ name: "a component that hands its className to a section state (Ship's Activity), followed and fine", filename: app, code: scrolled("import { Activity } from '@/components/Activity'", '') },
// UIG-42: a box around a page state. The column it asks for, and a section's inset, pass.
{ name: 'a page state in the flex column it asks for (Peek FilePage.tsx:315)', filename: app, code: pageIn('
') },
{ name: "a section state in a box that gives it the rows' inset (Katerina, 14 September)", filename: app, code: pageIn('
') },
{ name: 'a page state that is not alone in its padded box (Ship ProjectsView.tsx:30)', filename: app, code: pageIn('
Projects
') },
{ name: "props spread in may carry the scope, so it is not judged (the package's EmptyState story)", filename: app, code: pageIn('
') },
{
name: 'a box kept with a reason',
filename: app,
code: pageIn('<>\n{/* @estiva-escape(no-handmade-empty-state): a probe that keeps its box */}\n
\n>'),
},
],
invalid: [
// the hand-made line, each way it stands in for a list
{ name: 'the other side of a map (Ship Board.tsx:61 at 6693025)', code: jsx('
{rows.length === 0 ?
Nothing here
: rows.map((r) =>
{r}
)}
'), errors: handmade },
{ name: 'same shape, other class names and another tag (FilePicker.stories.tsx:25)', code: jsx('
{rows.length === 0 ?
Nothing chosen yet.
: rows.map((r) =>
{r}
)}
'), errors: handmade },
{ name: 'a map on the other side is enough, whatever the test', code: jsx('
{rows[0] === undefined ? Nothing here yet. : rows.map((r) =>
{r}
)}
'), errors: handmade },
{ name: 'under a test that the list is empty, in a box around it (CommandLauncher.tsx:1716 at d094006)', code: jsx('
{rows.length === 0 && (\n
\nNothing for that here.\n
\n)}
'), errors: handmade },
{ name: 'returned when the list is empty', code: component(' if (rows.length === 0) return
No tickets in this project yet.
\n return
{rows.map((r) =>
{r}
)}
'), errors: handmade },
{ name: "the empty side is the second: `rows.length ? … : …`", code: jsx('
'), errors: handmade },
{
name: "words that do not say \"no\": Peek's dialogs (StartTopicDialog.tsx:106), and a name in the sentence",
code: jsx(''),
errors: handmade,
},
{
name: 'an escape with no reason is not an escape',
code: jsx('
'),
errors: [{ messageId: 'escapeWithoutReason' }, ...handmade],
},
// UIG-42: a box that places a page state. Peek's direct-message view, and the starter's home page before UIG-10's fix.
{ name: 'a box that centres a page state', filename: app, code: pageIn('
'), errors: [{ messageId: 'boxedPage', data: { what: 'centres' } }] },
{
name: 'a box that centres and pads it, from the package',
filename: app,
code: "import { EmptyState } from '@estiva-app/ui'\nexport function Probe() {\n return (\n \n \n \n )\n}\n",
errors: [{ messageId: 'boxedPage', data: { what: 'centres and pads' } }],
},
// the wrong scope
{ name: 'a page state straight inside a ScrollArea', filename: app, code: scrolled('', '{rows.length === 0 && }'), errors: [{ messageId: 'wrongScope' }] },
{ name: 'scope="page" said out loud', filename: app, code: scrolled('', ''), errors: [{ messageId: 'wrongScope' }] },
{ name: "through the app's wrapper, one box down", filename: app, code: "import { ScrollArea } from '@estiva-app/ui'\nimport { EmptyState } from '@/components/ui/EmptyState'\nexport function Probe() {\n return
\n}\n", errors: [{ messageId: 'boxedPage', data: { what: 'pads' } }, { messageId: 'wrongScope' }] },
{
name: 'the file conversation at 7f22e5e~1: a view drawn in another file, scrolled here — both its states',
filename: app,
code: scrolled("import { ForeignConversationView } from '@/components/views/ForeignConversationView'", '\n\n'),
errors: [
{ messageId: 'wrongScopeVia', data: { component: 'ForeignConversationView', where: 'ForeignConversationView.tsx:3' } },
{ messageId: 'wrongScopeVia', data: { component: 'ForeignConversationView', where: 'ForeignConversationView.tsx:4' } },
],
},
{
name: 'the nested children at 7f22e5e~1: a page state under one row of a list, two components down in another file',
filename: app,
code: scrolled("import { FolderContentsView } from '@/components/views/FolderContentsView'", '\n\n'),
errors: [{ messageId: 'wrongScopeVia', data: { component: 'FolderContentsView', where: 'FolderContentsView.tsx:11' } }],
},
{
name: 'a component in the same file',
filename: app,
code: "import { ScrollArea, EmptyState } from '@estiva-app/ui'\nfunction Replies({ rows }: { rows: string[] }) {\n if (rows.length === 0) return \n return <>{rows.map((r) =>