@nocobase/app-template-default 1.0.0-beta.83 → 1.0.0-beta.84

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.
Files changed (77) hide show
  1. package/.agents/rules/build-and-deploy.md +1 -1
  2. package/.agents/rules/shell.md +4 -0
  3. package/AGENTS.md +7 -1
  4. package/README.MD +10 -2
  5. package/client/components/back-button.tsx +3 -5
  6. package/client/components/default-access-editor.tsx +13 -2
  7. package/client/components/feedback-form.tsx +474 -0
  8. package/client/components/lifecycle-diagram.tsx +10 -10
  9. package/client/components/permission-editor.tsx +396 -210
  10. package/client/extensions/nocobase-auth-forms/password-login-form.tsx +17 -6
  11. package/client/extensions/nocobase-auth-forms/password-registration-form.tsx +24 -7
  12. package/client/extensions/nocobase-auth-forms/password-reset-form.tsx +19 -7
  13. package/client/extensions/nocobase-auth-forms/password-reset-request-form.tsx +15 -7
  14. package/client/extensions/nocobase-device-approval/device-approval.tsx +7 -2
  15. package/client/extensions/nocobase-device-approval/locales/en-US.ts +16 -0
  16. package/client/extensions/nocobase-device-approval/locales/zh-CN.ts +7 -0
  17. package/client/extensions/nocobase-file-component-ui/components/file-preview-dialog.tsx +20 -17
  18. package/client/hooks/use-list-data.ts +77 -0
  19. package/client/hooks/use-list-location.ts +23 -0
  20. package/client/hooks/use-list-tabs.ts +42 -0
  21. package/client/hooks/use-table-url-state.ts +143 -30
  22. package/client/locales/system/en-US.ts +10 -0
  23. package/client/locales/system/settings-en-US.ts +8 -0
  24. package/client/locales/system/settings-zh-CN.ts +8 -0
  25. package/client/locales/system/zh-CN.ts +8 -0
  26. package/client/pages/settings/default-access-form.tsx +44 -26
  27. package/client/pages/settings/default-access.tsx +56 -27
  28. package/client/pages/settings/hooks.tsx +4 -59
  29. package/client/pages/settings/page-permissions.ts +17 -0
  30. package/client/pages/settings/permissions-model.ts +54 -58
  31. package/client/pages/settings/role-form.tsx +98 -56
  32. package/client/pages/settings/roles.tsx +11 -3
  33. package/client/pages/settings/shared.tsx +43 -33
  34. package/client/pages/settings/user-form.tsx +51 -30
  35. package/client/pages/settings/users.tsx +39 -27
  36. package/client/routing/session-preparation.tsx +10 -3
  37. package/database/main/migrations/202611020002_create_mutation_receipts.ts +19 -0
  38. package/package.json +13 -12
  39. package/server/providers/index.ts +4 -1
  40. package/server/providers/submission.ts +61 -0
  41. package/tests/client/auth-i18n.test.tsx +9 -0
  42. package/tests/client/back-button.test.tsx +3 -3
  43. package/tests/client/default-access.test.tsx +60 -4
  44. package/tests/client/form-feedback.test.tsx +589 -0
  45. package/tests/client/list-recovery.test.tsx +373 -0
  46. package/tests/client/list-tabs.test.tsx +121 -0
  47. package/tests/client/page-permission-navigation.test.ts +8 -0
  48. package/tests/client/permission-editor.test.tsx +191 -21
  49. package/tests/client/permission-filter-binding.test.tsx +1 -1
  50. package/tests/client/role-return.test.tsx +2 -1
  51. package/tests/client/session-preparation.test.tsx +24 -1
  52. package/tests/client/settings-continuity.test.tsx +5 -4
  53. package/tests/client/settings-feedback.test.tsx +25 -0
  54. package/tests/client/settings-permissions.test.ts +35 -0
  55. package/tests/client/settings.test.tsx +2 -1
  56. package/tests/client/table-url-state.test.tsx +117 -3
  57. package/tests/client/user-drawer.test.tsx +2 -1
  58. package/tests/client/user-permissions.test.tsx +30 -5
  59. package/tests/database/mutation-receipts.test.ts +45 -0
  60. package/tests/fixtures/form-feedback/README.md +33 -0
  61. package/tests/fixtures/form-feedback/equipment-detail.tsx +11 -0
  62. package/tests/fixtures/form-feedback/equipment-layout.tsx +15 -0
  63. package/tests/fixtures/form-feedback/equipment-migration.ts +27 -0
  64. package/tests/fixtures/form-feedback/equipment-server.ts +197 -0
  65. package/tests/fixtures/form-feedback/equipment-validation.tsx +94 -0
  66. package/tests/fixtures/form-feedback/equipment.tsx +369 -0
  67. package/tests/fixtures/form-feedback/verify-inline.mjs +174 -0
  68. package/tests/fixtures/form-feedback/verify.mjs +373 -0
  69. package/tests/fixtures/submission/README.md +35 -0
  70. package/tests/fixtures/submission/browser.test.ts +478 -0
  71. package/tests/fixtures/submission/migration.ts +61 -0
  72. package/tests/fixtures/submission/page.tsx +138 -0
  73. package/tests/fixtures/submission/playwright.config.ts +11 -0
  74. package/tests/fixtures/submission/routes.ts +208 -0
  75. package/tests/helpers/settings-client.tsx +31 -0
  76. package/tests/server/member-role.test.ts +7 -1
  77. package/tests/server/submission-settings.test.ts +241 -0
@@ -1,3 +1,4 @@
1
+ import { FeedbackForm } from '#components/feedback-form';
1
2
  import {
2
3
  useId,
3
4
  useState,
@@ -11,7 +12,7 @@ import { FieldGroup } from '#components/ui/field';
11
12
  import { Input } from '#components/ui/input';
12
13
 
13
14
  import { invalidProps } from './field-props.js';
14
- import { AuthField, AuthFormStatus, AuthFormSubmit } from './form-parts.js';
15
+ import { AuthField, AuthFormSubmit } from './form-parts.js';
15
16
  import { PasswordInput } from './password-input.js';
16
17
 
17
18
  export interface PasswordLoginValues {
@@ -71,13 +72,21 @@ export function PasswordLoginForm({
71
72
  const [identifier, setIdentifier] = useState('');
72
73
  const [password, setPassword] = useState('');
73
74
 
74
- const handleSubmit = (event: FormEvent<HTMLFormElement>): void => {
75
+ const handleSubmit = async (
76
+ event: FormEvent<HTMLFormElement>,
77
+ ): Promise<void> => {
75
78
  event.preventDefault();
76
- void onSubmit({ identifier: identifier.trim(), password });
79
+ await onSubmit({ identifier: identifier.trim(), password });
77
80
  };
78
81
 
79
82
  return (
80
- <form className={className} onSubmit={handleSubmit}>
83
+ <FeedbackForm
84
+ submitting={submitting}
85
+ error={error}
86
+ errorContent={error}
87
+ className={className}
88
+ onSubmit={handleSubmit}
89
+ >
81
90
  <FieldGroup>
82
91
  <AuthField
83
92
  error={fieldErrors?.identifier}
@@ -88,6 +97,7 @@ export function PasswordLoginForm({
88
97
  autoComplete='username'
89
98
  autoFocus
90
99
  id={identifierId}
100
+ name='identifier'
91
101
  onChange={(event) => setIdentifier(event.target.value)}
92
102
  required
93
103
  value={identifier}
@@ -104,6 +114,7 @@ export function PasswordLoginForm({
104
114
  autoComplete='current-password'
105
115
  hideLabel={labels.hidePassword}
106
116
  id={passwordId}
117
+ name='password'
107
118
  onChange={(event) => setPassword(event.target.value)}
108
119
  required
109
120
  showLabel={labels.showPassword}
@@ -111,13 +122,13 @@ export function PasswordLoginForm({
111
122
  {...invalidProps(passwordId, fieldErrors?.password)}
112
123
  />
113
124
  </AuthField>
114
- {error ? <AuthFormStatus type='error'>{error}</AuthFormStatus> : null}
125
+
115
126
  <AuthFormSubmit footer={footer}>
116
127
  <Button className='w-full' disabled={submitting} type='submit'>
117
128
  {submitting ? labels.submitting : labels.submit}
118
129
  </Button>
119
130
  </AuthFormSubmit>
120
131
  </FieldGroup>
121
- </form>
132
+ </FeedbackForm>
122
133
  );
123
134
  }
@@ -1,3 +1,4 @@
1
+ import { FeedbackForm } from '#components/feedback-form';
1
2
  import {
2
3
  useId,
3
4
  useState,
@@ -11,7 +12,7 @@ import { FieldGroup } from '#components/ui/field';
11
12
  import { Input } from '#components/ui/input';
12
13
 
13
14
  import { invalidProps } from './field-props.js';
14
- import { AuthField, AuthFormStatus, AuthFormSubmit } from './form-parts.js';
15
+ import { AuthField, AuthFormSubmit } from './form-parts.js';
15
16
 
16
17
  export interface PasswordRegistrationValues {
17
18
  readonly name: string;
@@ -91,14 +92,16 @@ export function PasswordRegistrationForm({
91
92
  (event: { target: { value: string } }): void =>
92
93
  setValues((current) => ({ ...current, [field]: event.target.value }));
93
94
 
94
- const handleSubmit = (event: FormEvent<HTMLFormElement>): void => {
95
+ const handleSubmit = async (
96
+ event: FormEvent<HTMLFormElement>,
97
+ ): Promise<void> => {
95
98
  event.preventDefault();
96
99
  if (values.password !== values.confirmation) {
97
100
  setMismatch(true);
98
101
  return;
99
102
  }
100
103
  setMismatch(false);
101
- void onSubmit({
104
+ await onSubmit({
102
105
  name: values.name.trim(),
103
106
  username: values.username.trim(),
104
107
  email: values.email.trim(),
@@ -106,16 +109,26 @@ export function PasswordRegistrationForm({
106
109
  });
107
110
  };
108
111
 
109
- const confirmationError = mismatch ? labels.passwordMismatch : undefined;
112
+ const confirmationError =
113
+ mismatch && values.password !== values.confirmation
114
+ ? labels.passwordMismatch
115
+ : undefined;
110
116
 
111
117
  return (
112
- <form className={className} onSubmit={handleSubmit}>
118
+ <FeedbackForm
119
+ submitting={submitting}
120
+ error={error}
121
+ errorContent={error}
122
+ className={className}
123
+ onSubmit={handleSubmit}
124
+ >
113
125
  <FieldGroup>
114
126
  <AuthField error={fieldErrors?.name} id={ids.name} label={labels.name}>
115
127
  <Input
116
128
  autoComplete='name'
117
129
  autoFocus
118
130
  id={ids.name}
131
+ name='name'
119
132
  onChange={set('name')}
120
133
  required
121
134
  value={values.name}
@@ -130,6 +143,7 @@ export function PasswordRegistrationForm({
130
143
  <Input
131
144
  autoComplete='username'
132
145
  id={ids.username}
146
+ name='username'
133
147
  onChange={set('username')}
134
148
  required
135
149
  value={values.username}
@@ -144,6 +158,7 @@ export function PasswordRegistrationForm({
144
158
  <Input
145
159
  autoComplete='email'
146
160
  id={ids.email}
161
+ name='email'
147
162
  onChange={set('email')}
148
163
  required
149
164
  type='email'
@@ -159,6 +174,7 @@ export function PasswordRegistrationForm({
159
174
  <Input
160
175
  autoComplete='new-password'
161
176
  id={ids.password}
177
+ name='password'
162
178
  onChange={set('password')}
163
179
  required
164
180
  type='password'
@@ -174,6 +190,7 @@ export function PasswordRegistrationForm({
174
190
  <Input
175
191
  autoComplete='new-password'
176
192
  id={ids.confirmation}
193
+ name='confirmation'
177
194
  onChange={set('confirmation')}
178
195
  required
179
196
  type='password'
@@ -181,13 +198,13 @@ export function PasswordRegistrationForm({
181
198
  {...invalidProps(ids.confirmation, confirmationError)}
182
199
  />
183
200
  </AuthField>
184
- {error ? <AuthFormStatus type='error'>{error}</AuthFormStatus> : null}
201
+
185
202
  <AuthFormSubmit footer={footer}>
186
203
  <Button className='w-full' disabled={submitting} type='submit'>
187
204
  {submitting ? labels.submitting : labels.submit}
188
205
  </Button>
189
206
  </AuthFormSubmit>
190
207
  </FieldGroup>
191
- </form>
208
+ </FeedbackForm>
192
209
  );
193
210
  }
@@ -1,3 +1,4 @@
1
+ import { FeedbackForm } from '#components/feedback-form';
1
2
  import {
2
3
  useId,
3
4
  useState,
@@ -11,7 +12,7 @@ import { FieldGroup } from '#components/ui/field';
11
12
  import { Input } from '#components/ui/input';
12
13
 
13
14
  import { invalidProps } from './field-props.js';
14
- import { AuthField, AuthFormStatus, AuthFormSubmit } from './form-parts.js';
15
+ import { AuthField, AuthFormSubmit } from './form-parts.js';
15
16
 
16
17
  export interface PasswordResetValues {
17
18
  readonly password: string;
@@ -66,7 +67,9 @@ export function PasswordResetForm({
66
67
  const [confirmation, setConfirmation] = useState('');
67
68
  const [mismatch, setMismatch] = useState(false);
68
69
 
69
- const handleSubmit = (event: FormEvent<HTMLFormElement>): void => {
70
+ const handleSubmit = async (
71
+ event: FormEvent<HTMLFormElement>,
72
+ ): Promise<void> => {
70
73
  event.preventDefault();
71
74
  if (disabled) return;
72
75
  if (password !== confirmation) {
@@ -74,13 +77,20 @@ export function PasswordResetForm({
74
77
  return;
75
78
  }
76
79
  setMismatch(false);
77
- void onSubmit({ password });
80
+ await onSubmit({ password });
78
81
  };
79
82
 
80
- const confirmationError = mismatch ? labels.passwordMismatch : undefined;
83
+ const confirmationError =
84
+ mismatch && password !== confirmation ? labels.passwordMismatch : undefined;
81
85
 
82
86
  return (
83
- <form className={className} onSubmit={handleSubmit}>
87
+ <FeedbackForm
88
+ submitting={submitting}
89
+ error={error}
90
+ errorContent={error}
91
+ className={className}
92
+ onSubmit={handleSubmit}
93
+ >
84
94
  <FieldGroup>
85
95
  <AuthField
86
96
  error={fieldErrors?.password}
@@ -92,6 +102,7 @@ export function PasswordResetForm({
92
102
  autoFocus
93
103
  disabled={disabled}
94
104
  id={passwordId}
105
+ name='password'
95
106
  onChange={(event) => setPassword(event.target.value)}
96
107
  required
97
108
  type='password'
@@ -108,6 +119,7 @@ export function PasswordResetForm({
108
119
  autoComplete='new-password'
109
120
  disabled={disabled}
110
121
  id={confirmationId}
122
+ name='confirmation'
111
123
  onChange={(event) => setConfirmation(event.target.value)}
112
124
  required
113
125
  type='password'
@@ -115,7 +127,7 @@ export function PasswordResetForm({
115
127
  {...invalidProps(confirmationId, confirmationError)}
116
128
  />
117
129
  </AuthField>
118
- {error ? <AuthFormStatus type='error'>{error}</AuthFormStatus> : null}
130
+
119
131
  <AuthFormSubmit footer={footer}>
120
132
  <Button
121
133
  className='w-full'
@@ -127,6 +139,6 @@ export function PasswordResetForm({
127
139
  </Button>
128
140
  </AuthFormSubmit>
129
141
  </FieldGroup>
130
- </form>
142
+ </FeedbackForm>
131
143
  );
132
144
  }
@@ -1,3 +1,4 @@
1
+ import { FeedbackForm } from '#components/feedback-form';
1
2
  import {
2
3
  useId,
3
4
  useState,
@@ -63,19 +64,28 @@ export function PasswordResetRequestForm({
63
64
  const emailId = `${useId()}-email`;
64
65
  const [email, setEmail] = useState('');
65
66
 
66
- const handleSubmit = (event: FormEvent<HTMLFormElement>): void => {
67
+ const handleSubmit = async (
68
+ event: FormEvent<HTMLFormElement>,
69
+ ): Promise<void> => {
67
70
  event.preventDefault();
68
- void onSubmit({ email: email.trim() });
71
+ await onSubmit({ email: email.trim() });
69
72
  };
70
73
 
71
74
  return (
72
- <form className={className} onSubmit={handleSubmit}>
75
+ <FeedbackForm
76
+ submitting={submitting}
77
+ error={error}
78
+ errorContent={error}
79
+ className={className}
80
+ onSubmit={handleSubmit}
81
+ >
73
82
  <FieldGroup>
74
83
  <AuthField error={fieldErrors?.email} id={emailId} label={labels.email}>
75
84
  <Input
76
85
  autoComplete='email'
77
86
  autoFocus
78
87
  id={emailId}
88
+ name='email'
79
89
  onChange={(event) => setEmail(event.target.value)}
80
90
  required
81
91
  type='email'
@@ -83,9 +93,7 @@ export function PasswordResetRequestForm({
83
93
  {...invalidProps(emailId, fieldErrors?.email)}
84
94
  />
85
95
  </AuthField>
86
- {error ? (
87
- <AuthFormStatus type='error'>{error}</AuthFormStatus>
88
- ) : success ? (
96
+ {!error && success ? (
89
97
  <AuthFormStatus type='success'>{labels.success}</AuthFormStatus>
90
98
  ) : null}
91
99
  <AuthFormSubmit footer={footer}>
@@ -94,6 +102,6 @@ export function PasswordResetRequestForm({
94
102
  </Button>
95
103
  </AuthFormSubmit>
96
104
  </FieldGroup>
97
- </form>
105
+ </FeedbackForm>
98
106
  );
99
107
  }
@@ -1,3 +1,4 @@
1
+ import { FeedbackForm } from '#components/feedback-form';
1
2
  import { useTranslation } from '@nocobase/i18n/client';
2
3
  import { CircleAlert, CircleCheck, CircleX, Clock } from 'lucide-react';
3
4
  import {
@@ -125,7 +126,10 @@ function CodeEntry({
125
126
  if (code) onSubmit(formatUserCode(code));
126
127
  };
127
128
  return (
128
- <form className={cn('flex flex-col gap-6', className)} onSubmit={submit}>
129
+ <FeedbackForm
130
+ className={cn('flex flex-col gap-6', className)}
131
+ onSubmit={submit}
132
+ >
129
133
  <FieldGroup>
130
134
  <Field>
131
135
  <FieldLabel htmlFor={id}>
@@ -137,6 +141,7 @@ function CodeEntry({
137
141
  autoFocus
138
142
  className='font-mono tracking-widest uppercase'
139
143
  id={id}
144
+ name='userCode'
140
145
  onChange={(event) => setTyped(event.target.value)}
141
146
  placeholder='ABCD-EFGH'
142
147
  spellCheck={false}
@@ -152,7 +157,7 @@ function CodeEntry({
152
157
  <Button disabled={typed.trim() === ''} type='submit'>
153
158
  {t('deviceApproval.continue', { defaultValue: 'Continue' })}
154
159
  </Button>
155
- </form>
160
+ </FeedbackForm>
156
161
  );
157
162
  }
158
163
 
@@ -1,5 +1,12 @@
1
1
  /** Every key the device approval looks up, so each locale is checked against the same list. */
2
2
  export interface DeviceApprovalLocale {
3
+ readonly formFeedback: {
4
+ readonly field: string;
5
+ readonly invalid: string;
6
+ readonly failed: string;
7
+ readonly network: string;
8
+ readonly denied: string;
9
+ };
3
10
  readonly 'deviceApproval.title': string;
4
11
  readonly 'deviceApproval.description': string;
5
12
  readonly 'deviceApproval.enterCode': string;
@@ -23,6 +30,15 @@ export interface DeviceApprovalLocale {
23
30
  }
24
31
 
25
32
  const enUS: DeviceApprovalLocale = {
33
+ formFeedback: {
34
+ field: 'This value was rejected. Check it and try again.',
35
+ invalid: 'Check the highlighted fields and try again.',
36
+ failed: 'Unable to save. Your input has been kept. Try again.',
37
+ network:
38
+ 'Unable to connect. Check your connection and try again. Your input has been kept.',
39
+ denied:
40
+ 'You cannot submit this form with your current access. Your input has been kept.',
41
+ },
26
42
  'deviceApproval.title': 'Authorize a device',
27
43
  'deviceApproval.description':
28
44
  'A command line or another device asked to sign in as you. Approve it only if you started the sign-in yourself.',
@@ -1,6 +1,13 @@
1
1
  import type { DeviceApprovalLocale } from './en-US.js';
2
2
 
3
3
  const zhCN: DeviceApprovalLocale = {
4
+ formFeedback: {
5
+ field: '此字段的值未通过校验,请检查并修改后重试。',
6
+ invalid: '请检查标记的字段后重试。',
7
+ failed: '保存失败,已保留您的输入,请重试。',
8
+ network: '无法连接,请检查网络后重试。已保留您的输入。',
9
+ denied: '当前权限无法提交此表单。已保留您的输入。',
10
+ },
4
11
  'deviceApproval.title': '授权设备',
5
12
  'deviceApproval.description':
6
13
  '一个命令行或其他设备请求以你的身份登录。只有在登录是你自己发起的时候才批准。',
@@ -86,10 +86,28 @@ function OpenFilePreviewDialog(
86
86
  >
87
87
  <div className='flex items-center justify-between gap-3 pr-10'>
88
88
  <div className='min-w-0'>
89
- <DialogTitle className='truncate'>{file.filename}</DialogTitle>
89
+ <div className='flex min-w-0 items-center gap-2'>
90
+ <DialogTitle className='truncate'>{file.filename}</DialogTitle>
91
+ {allowDownload ? (
92
+ <Button
93
+ type='button'
94
+ size='icon'
95
+ className='shrink-0'
96
+ variant='ghost'
97
+ aria-label={`${downloadLabel}: ${file.filename}`}
98
+ onClick={() =>
99
+ void downloadFile(file, onDownload).catch(
100
+ (error: unknown) => reportDownloadError(onError, error),
101
+ )
102
+ }
103
+ >
104
+ <Download aria-hidden='true' />
105
+ </Button>
106
+ ) : null}
107
+ </div>
90
108
  <p className='text-sm text-muted-foreground'>{file.mimeType}</p>
91
109
  </div>
92
- <div className='flex gap-1'>
110
+ <div className='flex shrink-0 gap-1'>
93
111
  {files.length > 1 ? (
94
112
  <>
95
113
  <Button
@@ -122,21 +140,6 @@ function OpenFilePreviewDialog(
122
140
  </Button>
123
141
  </>
124
142
  ) : null}
125
- {allowDownload ? (
126
- <Button
127
- type='button'
128
- size='icon'
129
- variant='ghost'
130
- aria-label={`${downloadLabel}: ${file.filename}`}
131
- onClick={() =>
132
- void downloadFile(file, onDownload).catch((error: unknown) =>
133
- reportDownloadError(onError, error),
134
- )
135
- }
136
- >
137
- <Download aria-hidden='true' />
138
- </Button>
139
- ) : null}
140
143
  </div>
141
144
  </div>
142
145
  {/* Only the preview scrolls; the title and its actions stay in view. */}
@@ -0,0 +1,77 @@
1
+ import { ApiClientError, useApiClient } from '@nocobase/app-client';
2
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
3
+
4
+ /** A successful result belongs to one query and refresh generation only. */
5
+ export function useListData<T>(
6
+ path: string,
7
+ options: boolean | { keepPreviousData?: boolean; active?: boolean } = false,
8
+ ) {
9
+ const { keepPreviousData = false, active = true } =
10
+ typeof options === 'boolean' ? { keepPreviousData: options } : options;
11
+ const api = useApiClient();
12
+ const [revision, setRevision] = useState(0);
13
+ const key = useMemo(
14
+ () => ({ path, revision, active }),
15
+ [path, revision, active],
16
+ );
17
+ const generationRef = useRef(0);
18
+ const controllerRef = useRef<AbortController | undefined>(undefined);
19
+ const [result, setResult] = useState<{
20
+ path: string;
21
+ key?: typeof key;
22
+ data?: T;
23
+ error?: unknown;
24
+ }>({ path });
25
+ useEffect(() => {
26
+ if (!active) return;
27
+ const request = new AbortController();
28
+ controllerRef.current = request;
29
+ const ticket = generationRef.current;
30
+ void api.request<T>({ path, signal: request.signal }).then(
31
+ (data) => {
32
+ if (!request.signal.aborted && ticket === generationRef.current)
33
+ setResult({ path, key, data });
34
+ },
35
+ (error: unknown) => {
36
+ if (request.signal.aborted || ticket !== generationRef.current) return;
37
+ setResult((old) => ({
38
+ ...(old.path === path &&
39
+ !(
40
+ error instanceof ApiClientError &&
41
+ [401, 403, 404].includes(error.status)
42
+ )
43
+ ? old
44
+ : {}),
45
+ path,
46
+ key,
47
+ error,
48
+ }));
49
+ },
50
+ );
51
+ return () => request.abort();
52
+ }, [api, path, key, active]);
53
+ const invalidate = useCallback(() => {
54
+ // Invalidate synchronously, before React commits the next request effect.
55
+ generationRef.current += 1;
56
+ controllerRef.current?.abort();
57
+ }, []);
58
+ return {
59
+ path: result.path,
60
+ data: result.path === path || keepPreviousData ? result.data : undefined,
61
+ loading: !active || result.path !== path || result.key !== key,
62
+ error:
63
+ result.path === path && result.key === key ? result.error : undefined,
64
+ revision,
65
+ reload: useCallback(() => {
66
+ invalidate();
67
+ setRevision((value) => value + 1);
68
+ }, [invalidate]),
69
+ replaceData: useCallback(
70
+ (data: T) => {
71
+ invalidate();
72
+ setResult({ path, key, data });
73
+ },
74
+ [path, key, invalidate],
75
+ ),
76
+ };
77
+ }
@@ -0,0 +1,23 @@
1
+ import {
2
+ useLocation,
3
+ useOutlet,
4
+ useResolvedPath,
5
+ useSearchParams,
6
+ } from 'react-router';
7
+ import { useRouteSearches } from '../routing/route-context.js';
8
+
9
+ /** Covered lists keep their own query; returning activates a fresh read. */
10
+ export function useListLocation() {
11
+ const location = useLocation();
12
+ const listPath = useResolvedPath('.').pathname;
13
+ const child = useOutlet();
14
+ const searches = useRouteSearches();
15
+ const [current, setParams] = useSearchParams();
16
+ const active =
17
+ (listPath === '/' && child === null) ||
18
+ location.pathname.replace(/\/$/u, '') === listPath.replace(/\/$/u, '');
19
+ const params = active
20
+ ? current
21
+ : new URLSearchParams(searches.get(listPath) ?? '');
22
+ return { params, setParams, active };
23
+ }
@@ -0,0 +1,42 @@
1
+ import { useLocation, useResolvedPath } from 'react-router';
2
+ import { useRouteSearches } from '../routing/route-context.js';
3
+
4
+ export interface ListTab {
5
+ readonly path: string;
6
+ readonly label: string;
7
+ /** Tabs sharing a dataset may retain its filters, sorting and pagination. */
8
+ readonly dataset: string;
9
+ }
10
+
11
+ /** Tabs are routes; only a list's own parameters are discarded on a dataset change. */
12
+ export function useListTabs({
13
+ tabs,
14
+ parameterKeys,
15
+ pending = false,
16
+ }: {
17
+ tabs: readonly ListTab[];
18
+ parameterKeys: readonly string[];
19
+ pending?: boolean;
20
+ }) {
21
+ const location = useLocation();
22
+ const parent = useResolvedPath('.').pathname.replace(/\/+$/u, '');
23
+ const searches = useRouteSearches();
24
+ const segment = location.pathname.slice(parent.length + 1).split('/')[0];
25
+ const active = tabs.find((tab) => tab.path === segment);
26
+ const destination = (tab: ListTab) => {
27
+ const params = new URLSearchParams(location.search);
28
+ if (!active || tab.dataset !== active.dataset) {
29
+ for (const key of parameterKeys) params.delete(key);
30
+ }
31
+ return { pathname: `${parent}/${tab.path}`, search: params.toString() };
32
+ };
33
+ const activePath = active ? `${parent}/${active.path}` : undefined;
34
+ return {
35
+ active,
36
+ source: activePath
37
+ ? { pathname: activePath, search: searches.get(activePath) ?? '' }
38
+ : undefined,
39
+ destination,
40
+ fallback: !pending && !active && tabs[0] ? destination(tabs[0]) : undefined,
41
+ };
42
+ }