nucleus-core-ts 0.9.884 → 0.9.885

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/.build-ok CHANGED
@@ -1 +1 @@
1
- 0.9.884
1
+ 0.9.885
@@ -130,6 +130,7 @@ export function ChatPanel(props) {
130
130
  currentUserId: props.currentUserId,
131
131
  theme: theme,
132
132
  isCreating: props.actions.createConversation.state.isPending,
133
+ labels: props.labels,
133
134
  onClose: ()=>setPickerOpen(false),
134
135
  onStartDirect: handleStartDirect
135
136
  }),
@@ -1,4 +1,5 @@
1
1
  import { type ReactElement } from 'react';
2
+ import { type ChatPanelLabels } from '../labels';
2
3
  import type { ChatPanelTheme } from '../theme';
3
4
  import type { ChatDirectoryUser } from '../types';
4
5
  type NewConversationModalProps = {
@@ -7,11 +8,9 @@ type NewConversationModalProps = {
7
8
  currentUserId: string;
8
9
  theme: ChatPanelTheme;
9
10
  isCreating: boolean;
10
- title?: string;
11
- searchPlaceholder?: string;
12
- emptyLabel?: string;
11
+ labels?: Partial<ChatPanelLabels>;
13
12
  onClose: () => void;
14
13
  onStartDirect: (userId: string) => void;
15
14
  };
16
- export declare function NewConversationModal({ open, directory, currentUserId, theme, isCreating, title, searchPlaceholder, emptyLabel, onClose, onStartDirect, }: NewConversationModalProps): ReactElement | null;
15
+ export declare function NewConversationModal({ open, directory, currentUserId, theme, isCreating, labels: labelOverrides, onClose, onStartDirect, }: NewConversationModalProps): ReactElement | null;
17
16
  export {};
@@ -2,18 +2,29 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useState } from 'react';
4
4
  import { cn } from '../../../utils/cn';
5
+ import { matchesQuery } from '../../../utils/searchFold';
5
6
  import { initialsOf } from '../helpers';
6
- export function NewConversationModal({ open, directory, currentUserId, theme, isCreating, title = 'New message', searchPlaceholder = 'Search people...', emptyLabel = 'No people found', onClose, onStartDirect }) {
7
+ import { DEFAULT_CHAT_LABELS } from '../labels';
8
+ export function NewConversationModal({ open, directory, currentUserId, theme, isCreating, labels: labelOverrides, onClose, onStartDirect }) {
7
9
  const [query, setQuery] = useState('');
10
+ const labels = {
11
+ ...DEFAULT_CHAT_LABELS,
12
+ ...labelOverrides
13
+ };
8
14
  if (!open) return null;
9
- const normalized = query.trim().toLowerCase();
10
- const people = directory.filter((person)=>person.userId !== currentUserId).filter((person)=>normalized ? person.name.toLowerCase().includes(normalized) || (person.subtitle?.toLowerCase().includes(normalized) ?? false) : true);
15
+ // Folded, not lower-cased. A directory exported from a personnel system
16
+ // arrives in capitals, and `İSMAİL`.toLowerCase() is an `i` followed by a
17
+ // combining dot — so typing `ismail` used to find nobody at all.
18
+ const people = directory.filter((person)=>person.userId !== currentUserId).filter((person)=>matchesQuery(query, [
19
+ person.name,
20
+ person.subtitle
21
+ ]));
11
22
  return /*#__PURE__*/ _jsxs("div", {
12
23
  className: theme.picker.overlay,
13
24
  children: [
14
25
  /*#__PURE__*/ _jsx("button", {
15
26
  type: "button",
16
- "aria-label": "Close",
27
+ "aria-label": labels.close,
17
28
  className: "absolute inset-0 h-full w-full cursor-default",
18
29
  onClick: onClose
19
30
  }),
@@ -21,20 +32,20 @@ export function NewConversationModal({ open, directory, currentUserId, theme, is
21
32
  className: cn('relative', theme.picker.panel),
22
33
  role: "dialog",
23
34
  "aria-modal": "true",
24
- "aria-label": title,
35
+ "aria-label": labels.newMessageTitle,
25
36
  children: [
26
37
  /*#__PURE__*/ _jsxs("div", {
27
38
  className: theme.picker.header,
28
39
  children: [
29
40
  /*#__PURE__*/ _jsx("span", {
30
41
  className: theme.picker.title,
31
- children: title
42
+ children: labels.newMessageTitle
32
43
  }),
33
44
  /*#__PURE__*/ _jsx("button", {
34
45
  type: "button",
35
46
  className: theme.picker.closeButton,
36
47
  onClick: onClose,
37
- "aria-label": "Close",
48
+ "aria-label": labels.close,
38
49
  children: /*#__PURE__*/ _jsxs("svg", {
39
50
  className: "h-4 w-4",
40
51
  fill: "none",
@@ -42,7 +53,7 @@ export function NewConversationModal({ open, directory, currentUserId, theme, is
42
53
  stroke: "currentColor",
43
54
  children: [
44
55
  /*#__PURE__*/ _jsx("title", {
45
- children: "Close"
56
+ children: labels.close
46
57
  }),
47
58
  /*#__PURE__*/ _jsx("path", {
48
59
  strokeLinecap: "round",
@@ -60,7 +71,7 @@ export function NewConversationModal({ open, directory, currentUserId, theme, is
60
71
  children: /*#__PURE__*/ _jsx("input", {
61
72
  type: "text",
62
73
  className: theme.picker.search,
63
- placeholder: searchPlaceholder,
74
+ placeholder: labels.searchPeople,
64
75
  value: query,
65
76
  onChange: (event)=>setQuery(event.target.value)
66
77
  })
@@ -69,7 +80,7 @@ export function NewConversationModal({ open, directory, currentUserId, theme, is
69
80
  className: theme.picker.list,
70
81
  children: people.length === 0 ? /*#__PURE__*/ _jsx("div", {
71
82
  className: theme.picker.empty,
72
- children: emptyLabel
83
+ children: labels.noPeopleFound
73
84
  }) : people.map((person)=>/*#__PURE__*/ _jsxs("button", {
74
85
  type: "button",
75
86
  className: theme.picker.item,
@@ -28,5 +28,13 @@ export type ChatPanelLabels = {
28
28
  removeFile: string;
29
29
  newConversation: string;
30
30
  close: string;
31
+ /**
32
+ * The people-picker that opens from `newConversation`. Its three strings were
33
+ * props on the modal and on nothing else, so a portal could translate the
34
+ * button that opens it and not a word inside it.
35
+ */
36
+ newMessageTitle: string;
37
+ searchPeople: string;
38
+ noPeopleFound: string;
31
39
  };
32
40
  export declare const DEFAULT_CHAT_LABELS: ChatPanelLabels;
@@ -20,5 +20,8 @@
20
20
  attachFiles: 'Attach files',
21
21
  removeFile: 'Remove file',
22
22
  newConversation: 'New conversation',
23
- close: 'Close'
23
+ close: 'Close',
24
+ newMessageTitle: 'New message',
25
+ searchPeople: 'Search people...',
26
+ noPeopleFound: 'No people found'
24
27
  };
@@ -3,6 +3,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
3
3
  import { useGSAP } from '@gsap/react';
4
4
  import gsap from 'gsap';
5
5
  import { useEffect, useRef, useState } from 'react';
6
+ import { matchesQuery } from '../../../utils/searchFold';
6
7
  import { selectBoxTheme } from '../theme';
7
8
  import { cn } from '../utils/cn';
8
9
  import { SelectDropdown } from './SelectDropdown';
@@ -23,7 +24,13 @@ export function SelectBox({ options, value: controlledValue, defaultValue, place
23
24
  const value = controlledValue !== undefined ? controlledValue : internalValue;
24
25
  const isControlled = controlledValue !== undefined;
25
26
  const selectedOption = options.find((opt)=>opt.value === value);
26
- const filteredOptions = searchable && searchQuery ? options.filter((opt)=>opt.label.toLowerCase().includes(searchQuery.toLowerCase())) : options;
27
+ // Folded rather than lower-cased: the labels in these dropdowns are whatever
28
+ // language the install runs in, and a plain `toLowerCase()` cannot match
29
+ // `Şube` to `sube` or `İzmir` to `izmir` — which is every second option in a
30
+ // Turkish portal.
31
+ const filteredOptions = searchable && searchQuery ? options.filter((opt)=>matchesQuery(searchQuery, [
32
+ opt.label
33
+ ])) : options;
27
34
  const calculatePosition = ()=>{
28
35
  if (position !== 'auto') {
29
36
  setDropdownPosition(position);
@@ -3,6 +3,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useEffect, useRef, useState } from 'react';
4
4
  import { createPortal } from 'react-dom';
5
5
  import { cn } from '../../../utils/cn';
6
+ import { matchesQuery } from '../../../utils/searchFold';
6
7
  export function RoleAssignmentModal({ isOpen, roles, userRoles, userId, onClose, onAssignRole, onRemoveRole }) {
7
8
  const modalRef = useRef(null);
8
9
  const [searchQuery, setSearchQuery] = useState('');
@@ -37,7 +38,11 @@ export function RoleAssignmentModal({ isOpen, roles, userRoles, userId, onClose,
37
38
  ]);
38
39
  if (!isOpen || !mounted) return null;
39
40
  const assignedRoleIds = userRoles.filter((ur)=>ur.userId === userId).map((ur)=>ur.roleId);
40
- const filteredRoles = roles.filter((role)=>role.name.toLowerCase().includes(searchQuery.toLowerCase()));
41
+ // Role names are written in the portal's own language, so the query is
42
+ // folded rather than lower-cased.
43
+ const filteredRoles = roles.filter((role)=>matchesQuery(searchQuery, [
44
+ role.name
45
+ ]));
41
46
  const handleToggleRole = (role)=>{
42
47
  const isAssigned = assignedRoleIds.includes(role.id);
43
48
  if (isAssigned) {
@@ -4,6 +4,7 @@ import { useGSAP } from '@gsap/react';
4
4
  import gsap from 'gsap';
5
5
  import { useEffect, useEffectEvent, useRef, useState } from 'react';
6
6
  import { cn } from '../../../utils/cn';
7
+ import { matchesQuery } from '../../../utils/searchFold';
7
8
  import { Button } from '../../Button';
8
9
  import { useUsersStore } from '../store';
9
10
  import { usersPageTheme } from '../theme';
@@ -353,8 +354,13 @@ export function UsersPage({ title = 'User Management', subtitle, className, them
353
354
  };
354
355
  const filteredUsers = store.users.filter((user)=>{
355
356
  const profile = store.profiles.find((p)=>p.userId === user.id);
356
- const displayName = profile?.firstName || profile?.lastName ? `${profile.firstName || ''} ${profile.lastName || ''}`.toLowerCase() : '';
357
- const matchesSearch = store.searchQuery === '' || (user.email ?? '').toLowerCase().includes(store.searchQuery.toLowerCase()) || displayName.includes(store.searchQuery.toLowerCase());
357
+ // People's names, so the comparison is folded: a roster written `İSMAİL`
358
+ // has to answer to `ismail`, which lower-casing alone never manages.
359
+ const matchesSearch = matchesQuery(store.searchQuery, [
360
+ user.email,
361
+ profile?.firstName,
362
+ profile?.lastName
363
+ ]);
358
364
  const matchesStatus = store.statusFilter === 'all' || store.statusFilter === 'locked' && user.isLocked || store.statusFilter === 'active' && !user.isLocked && user.verifiedAt || store.statusFilter === 'unverified' && !user.verifiedAt;
359
365
  const matchesRole = store.roleFilter === 'all' || store.userRoles.some((ur)=>ur.userId === user.id && ur.roleId === store.roleFilter);
360
366
  return matchesSearch && matchesStatus && matchesRole;
@@ -60,5 +60,6 @@ export type { PubSubConfig, PubSubConnectionState, PubSubEvent } from './hooks/u
60
60
  export { usePubSub, usePubSubStore } from './hooks/usePubSub';
61
61
  export type { BulkDeleteResponse, ColumnConfig, ColumnEnum, ColumnReference, ColumnType, ColumnValidation, DeleteResponse, FieldConfig, FilterCondition, ListResponse, MutationResponse, NucleusColumn, NucleusEntity, NucleusEntityShowcaseProps, PaginationMeta, QueryParams, SingleResponse, SortCondition, SortDirection, UseNucleusEntityOptions, UseNucleusEntityReturn, } from './types';
62
62
  export { cn } from './utils/cn';
63
+ export { foldForSearch, matchesQuery } from './utils/searchFold';
63
64
  export { formatLabel, getColumnType, getDefaultValue, isArrayColumn, isJsonColumn, isReferenceColumn, shouldExcludeColumn, shouldExcludeFromForm, } from './utils/columnUtils';
64
65
  export { generateBulkEndpointKey, generateDistinctEndpointKey, generateEntityEndpointKey, generateGetByIdEndpointKey, singularize, toUpperSnakeCase, } from './utils/endpointKeys';
package/dist/fe/index.js CHANGED
@@ -36,5 +36,9 @@ export { useVerifyEmailStore, VerifyEmailPage } from './components/VerifyEmailPa
36
36
  export { useNucleusEntity } from './hooks/useNucleusEntity';
37
37
  export { usePubSub, usePubSubStore } from './hooks/usePubSub';
38
38
  export { cn } from './utils/cn';
39
+ // Exported because every portal that lists people needs it and none of them
40
+ // should write it twice: matching a typed query to a name is not a lower-case
41
+ // comparison in any alphabet with marks in it.
42
+ export { foldForSearch, matchesQuery } from './utils/searchFold';
39
43
  export { formatLabel, getColumnType, getDefaultValue, isArrayColumn, isJsonColumn, isReferenceColumn, shouldExcludeColumn, shouldExcludeFromForm } from './utils/columnUtils';
40
44
  export { generateBulkEndpointKey, generateDistinctEndpointKey, generateEntityEndpointKey, generateGetByIdEndpointKey, singularize, toUpperSnakeCase } from './utils/endpointKeys';
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Folding a string down to what a person typing into a search box meant.
3
+ *
4
+ * A search box that compares `toLowerCase()` to `toLowerCase()` only works for
5
+ * the alphabet it was written in. Everywhere else it fails in two directions at
6
+ * once, and the dotted i is the clearest case: `İSMAİL`.toLowerCase() is not
7
+ * `ismail` but `i̇smai̇l` — an `i` followed by a combining dot, which is a
8
+ * character nobody's keyboard produces. Type `ismail` into a directory of eight
9
+ * hundred people exported in capitals and it finds none of them. The same box
10
+ * cannot find `Şafak` from `safak`, or `Café` from `cafe`, which is how people
11
+ * search when the marks are a keystroke away rather than a key.
12
+ *
13
+ * The fold is three passes and no per-language table:
14
+ *
15
+ * 1. Lower-case with the INVARIANT rules. Turkish's own casing would send `I`
16
+ * to `ı`, which is right for writing and wrong here — a search box wants
17
+ * `I`, `İ`, `ı` and `i` to end up as the same letter, and they do once the
18
+ * marks come off.
19
+ * 2. Decompose (NFD) and drop the combining marks. This is what turns the
20
+ * stray dot above into nothing, and `é`, `ş`, `ğ`, `ü`, `ö`, `ç` into their
21
+ * plain letters, for every script that composes that way.
22
+ * 3. Map the few letters that are their own character rather than a letter
23
+ * plus a mark, so NFD has nothing to strip: the dotless `ı`, and `ø`, `ł`,
24
+ * `đ`, `ß`, `æ`, `œ`.
25
+ *
26
+ * Language-neutral by construction, which is the point: the kit ships to
27
+ * whoever installs it.
28
+ */
29
+ export declare function foldForSearch(value: string): string;
30
+ /**
31
+ * True when every word typed appears somewhere in the given fields.
32
+ *
33
+ * Word by word rather than as one string, because the reader should not have to
34
+ * guess the order a row prints its parts in: `yalgi genel` finds the general
35
+ * manager whether the list renders the name before the post or after it.
36
+ *
37
+ * An empty query matches everything, so a caller can hand the raw input over
38
+ * without checking it first.
39
+ */
40
+ export declare function matchesQuery(query: string, fields: readonly (string | null | undefined)[]): boolean;
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Folding a string down to what a person typing into a search box meant.
3
+ *
4
+ * A search box that compares `toLowerCase()` to `toLowerCase()` only works for
5
+ * the alphabet it was written in. Everywhere else it fails in two directions at
6
+ * once, and the dotted i is the clearest case: `İSMAİL`.toLowerCase() is not
7
+ * `ismail` but `i̇smai̇l` — an `i` followed by a combining dot, which is a
8
+ * character nobody's keyboard produces. Type `ismail` into a directory of eight
9
+ * hundred people exported in capitals and it finds none of them. The same box
10
+ * cannot find `Şafak` from `safak`, or `Café` from `cafe`, which is how people
11
+ * search when the marks are a keystroke away rather than a key.
12
+ *
13
+ * The fold is three passes and no per-language table:
14
+ *
15
+ * 1. Lower-case with the INVARIANT rules. Turkish's own casing would send `I`
16
+ * to `ı`, which is right for writing and wrong here — a search box wants
17
+ * `I`, `İ`, `ı` and `i` to end up as the same letter, and they do once the
18
+ * marks come off.
19
+ * 2. Decompose (NFD) and drop the combining marks. This is what turns the
20
+ * stray dot above into nothing, and `é`, `ş`, `ğ`, `ü`, `ö`, `ç` into their
21
+ * plain letters, for every script that composes that way.
22
+ * 3. Map the few letters that are their own character rather than a letter
23
+ * plus a mark, so NFD has nothing to strip: the dotless `ı`, and `ø`, `ł`,
24
+ * `đ`, `ß`, `æ`, `œ`.
25
+ *
26
+ * Language-neutral by construction, which is the point: the kit ships to
27
+ * whoever installs it.
28
+ */ /** Letters with no decomposition, so step 2 cannot reach them. */ const STANDALONE = {
29
+ ı: 'i',
30
+ ø: 'o',
31
+ ł: 'l',
32
+ đ: 'd',
33
+ ð: 'd',
34
+ þ: 'th',
35
+ ß: 'ss',
36
+ æ: 'ae',
37
+ œ: 'oe'
38
+ };
39
+ const COMBINING_MARKS = /\p{M}/gu;
40
+ export function foldForSearch(value) {
41
+ const lowered = value.toLowerCase().normalize('NFD').replace(COMBINING_MARKS, '');
42
+ let out = '';
43
+ for (const char of lowered)out += STANDALONE[char] ?? char;
44
+ return out;
45
+ }
46
+ /**
47
+ * True when every word typed appears somewhere in the given fields.
48
+ *
49
+ * Word by word rather than as one string, because the reader should not have to
50
+ * guess the order a row prints its parts in: `yalgi genel` finds the general
51
+ * manager whether the list renders the name before the post or after it.
52
+ *
53
+ * An empty query matches everything, so a caller can hand the raw input over
54
+ * without checking it first.
55
+ */ export function matchesQuery(query, fields) {
56
+ const words = foldForSearch(query).split(/\s+/).filter(Boolean);
57
+ if (words.length === 0) return true;
58
+ const haystack = foldForSearch(fields.filter(Boolean).join(' '));
59
+ return words.every((word)=>haystack.includes(word));
60
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nucleus-core-ts",
3
- "version": "0.9.884",
3
+ "version": "0.9.885",
4
4
  "description": "Production-ready, enterprise-grade TypeScript framework for building multi-tenant APIs",
5
5
  "author": "Hidayet Can Özcan <hidayetcan@gmail.com>",
6
6
  "license": "SEE LICENSE IN LICENSE",