From fb41ef36c63497718541db303afd196a8ed8803a Mon Sep 17 00:00:00 2001 From: David Nichols Date: Thu, 5 Nov 2026 09:49:11 +0100 Subject: [PATCH 1/7] feat(labels): a host gives every word of templates, editors and forms with setReqraftLabels (0.10.74) setReqraftLabels() merges a host's words over the English defaults, so a host that gives nothing has the English it had. It covers the template list, tree and picker, a template field's controls, value tabs and saved values, the editors, and FormEngine. What is drawn changes when the language changes, and a list kept between forms is built again from what the server said. Words are one setting for the page because trees, completions and cached lists are built outside components. No code decides anything by comparing a word: a template context says so with a flag. --- __tests__/reqraftLabels.test.tsx | 185 +++++ __tests__/reqraftLabelsDrawn.test.tsx | 45 ++ __tests__/templateBrowserKeyAction.test.tsx | 40 +- design/IDE_INTEGRATION.md | 9 + package.json | 2 +- src/components/codeSize/index.tsx | 5 +- src/components/dpqlEditor/DpqlEditor.tsx | 19 +- src/components/dpqlEditor/dpqlCompletions.ts | 26 +- src/components/dpqlEditor/useDpqlSession.ts | 5 +- src/components/form/engine/CompactRow.tsx | 142 ++-- src/components/form/engine/CompactToolbar.tsx | 54 +- src/components/form/engine/FormEngine.tsx | 74 +- .../form/engine/HiddenFieldsNote.tsx | 11 +- .../form/engine/OptionFieldMessages.tsx | 28 +- .../form/engine/OptionsHelpDialog.tsx | 9 +- .../_structuredData/StructuredDataView.tsx | 5 +- src/components/form/engine/optionActions.ts | 8 +- src/components/form/engine/readFirst.ts | 40 +- .../form/fields/rich-text/RichText.tsx | 8 +- .../form/fields/rich-text/useTemplateTags.ts | 8 +- .../form/fields/template/TemplateBrowser.tsx | 4 +- .../form/fields/template/TemplateField.tsx | 157 ++-- .../form/fields/template/TemplatePicker.tsx | 63 +- .../form/fields/template/referenceNames.tsx | 6 +- src/components/smartEditor/CompletionMenu.tsx | 38 +- src/components/smartEditor/SmartEditor.tsx | 26 +- src/components/smartEditor/contextTitle.tsx | 3 +- .../smartEditor/templateCompletions.ts | 43 +- src/components/smartEditor/useEditingAids.tsx | 42 +- .../smartEditor/useLspAutocomplete.ts | 46 +- src/helpers/noDescription.ts | 4 +- src/helpers/reqraftLabels.ts | 699 ++++++++++++++++++ src/helpers/templateCatalogue.ts | 9 +- src/helpers/templateTree.ts | 90 ++- src/helpers/templates.ts | 9 +- src/hooks/useTemplates.ts | 31 +- src/index.tsx | 1 + 37 files changed, 1499 insertions(+), 495 deletions(-) create mode 100644 __tests__/reqraftLabels.test.tsx create mode 100644 __tests__/reqraftLabelsDrawn.test.tsx create mode 100644 src/helpers/reqraftLabels.ts diff --git a/__tests__/reqraftLabels.test.tsx b/__tests__/reqraftLabels.test.tsx new file mode 100644 index 00000000..824f0399 --- /dev/null +++ b/__tests__/reqraftLabels.test.tsx @@ -0,0 +1,185 @@ +// Copyright 2026 Qore Technologies, s.r.o. +/* A host's words reach what Reqraft draws: merged over the English defaults, and again when its language changes. */ +import { render, renderHook, screen, act } from '@testing-library/react'; +import { ReqoreUIProvider } from '@qoretechnologies/reqore'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + CONTEXT_DATA_PAYLOAD, + QOG_STATE_DATA_PAYLOAD, +} from '../src/components/form/fields/template/__fixtures__/qogTemplatePayloads'; +import { buildTemplates } from '../src/helpers/templates'; +import { + DEFAULT_REQRAFT_LABELS, + reqraftLabels, + setReqraftLabels, + useReqraftLabels, +} from '../src/helpers/reqraftLabels'; +import { templateNodeKindLabel, templateTree, referenceNamesNothingMessage } from '../src/helpers/templateTree'; +import { useTemplates, seedTemplatesCache, TemplatesCache } from '../src/hooks/useTemplates'; +import { hiddenFieldNoteText } from '../src/components/form/engine/HiddenFieldsNote'; +import { formatOptionValue } from '../src/components/form/engine/readFirst'; +import { formatCodeLines } from '../src/components/codeSize'; +import { useWholeSays } from '../src/components/form/fields/template/TemplatePicker'; +import { browseAllTemplatesItem } from '../src/components/smartEditor/templateCompletions'; +import { dpqlEmptyCompletionMessage } from '../src/components/dpqlEditor/dpqlCompletions'; +import { toDropdownItems } from '../src/components/smartEditor/useLspAutocomplete'; +import { describeDependencies } from '../src/components/form/engine/OptionFieldMessages'; + +const payload = { ...CONTEXT_DATA_PAYLOAD, ...QOG_STATE_DATA_PAYLOAD } as never; + +/** Every description in a list of templates, however deep. */ +const allDescriptions = (items: unknown[] = []): string => + (items as { description?: unknown; items?: unknown[] }[]) + .map((item) => `${item.description ?? ''} ${allDescriptions(item.items)}`) + .join(' '); + +afterEach(() => { + setReqraftLabels(); + TemplatesCache.clear(); +}); + +describe('the labels', () => { + it('are the English defaults until a host gives others', () => { + expect(reqraftLabels()).toEqual(DEFAULT_REQRAFT_LABELS); + setReqraftLabels({ formEngine: { save: 'Speichern' } }); + expect(reqraftLabels().formEngine.save).toBe('Speichern'); + // the rest stay English, here and in the other parts + expect(reqraftLabels().formEngine.discard).toBe('Discard'); + expect(reqraftLabels().editor).toEqual(DEFAULT_REQRAFT_LABELS.editor); + }); + + it('merge a map entry by entry', () => { + setReqraftLabels({ templateTree: { kinds: { field: 'Feld' } } }); + expect(templateNodeKindLabel('field')).toBe('Feld'); + expect(templateNodeKindLabel('variable')).toBe('Variable'); + expect(templateNodeKindLabel('group')).toBeUndefined(); + }); + + it('skip a word given as undefined', () => { + setReqraftLabels({ formEngine: { save: undefined } }); + expect(reqraftLabels().formEngine.save).toBe('Save'); + }); + + it('replace what the last call gave, and say so to a component', () => { + const { result } = renderHook(() => useReqraftLabels().formEngine.save); + expect(result.current).toBe('Save'); + act(() => setReqraftLabels({ formEngine: { save: 'Speichern' } })); + expect(result.current).toBe('Speichern'); + act(() => setReqraftLabels({ formEngine: { discard: 'Verwerfen' } })); + expect(result.current).toBe('Save'); + }); + + it('let a component go without leaving a listener', () => { + const { result, unmount } = renderHook(() => useReqraftLabels()); + const seen = result.current; + unmount(); + act(() => setReqraftLabels({ formEngine: { save: 'Speichern' } })); + expect(result.current).toBe(seen); + }); +}); + +describe('a list of templates', () => { + const description = (templates?: ReturnType) => + allDescriptions(templates?.items); + + it('reads in the host’s words; those it is given itself come first', () => { + setReqraftLabels({ templateList: { exampleValue: (value) => `Beispiel: ${value}` } }); + expect(description(buildTemplates(payload))).toContain('Beispiel:'); + expect(description(buildTemplates(payload, { exampleValue: (value) => `Ex ${value}` }))).toContain('Ex '); + }); + + it('kept between forms is read again when the language changes, without asking the server', () => { + seedTemplatesCache('generic', payload); + const { result } = renderHook(() => useTemplates(true, undefined, 'generic')); + expect(description(result.current.value)).toContain('Example value:'); + act(() => setReqraftLabels({ templateList: { exampleValue: (value) => `Beispiel: ${value}` } })); + expect(description(result.current.value)).toContain('Beispiel:'); + }); +}); + +describe('the template tree', () => { + const fields = { order: { type: 'string' } } as never; + + it('names its groups in the host’s words, also after a language change', () => { + const named = () => templateTree(buildTemplates(payload), { fields }).map((node) => node.label); + expect(named()).toContain('Record fields'); + setReqraftLabels({ templateTree: { recordFields: 'Datensatzfelder' } }); + expect(named()).toContain('Datensatzfelder'); + setReqraftLabels(); + expect(named()).toContain('Record fields'); + }); + + it('says what a reference that names nothing is', () => { + setReqraftLabels({ templateTree: { referenceNamesNothing: (written) => `Nichts: ${written}` } }); + expect(referenceNamesNothingMessage('$x:')).toBe('Nichts: $x:'); + }); + + it('says what the picker’s Use takes', () => { + setReqraftLabels({ templateBrowser: { useWhole: (label) => `Ganz: ${label}` } }); + expect(useWholeSays('Order')).toBe('Ganz: Order'); + }); +}); + +describe('the editor', () => { + it('names a completion’s kind in the host’s words', () => { + const items = () => toDropdownItems([{ label: 'a', kind: 3 } as never]); + expect(items()[0].kindLabel).toBe('Function'); + setReqraftLabels({ editor: { completionKinds: { 3: 'Funktion' } } }); + expect(items()[0].kindLabel).toBe('Funktion'); + }); + + it('offers the way to all templates in the host’s words', () => { + setReqraftLabels({ editor: { browseAllTemplates: 'Alle Vorlagen' } }); + expect(browseAllTemplatesItem().label).toBe('Alle Vorlagen'); + }); + + it('says why nothing is listed in the host’s words', () => { + setReqraftLabels({ editor: { noTemplates: 'Keine Vorlagen' } }); + expect(dpqlEmptyCompletionMessage('$', 1)).toBe('Keine Vorlagen'); + }); +}); + +describe('a form', () => { + it('says what a value reads as in the host’s words', () => { + const format = () => formatOptionValue({ type: 'bool', value: true } as never, { type: 'bool' } as never); + expect(format()).toBe('Yes'); + setReqraftLabels({ formEngine: { yes: 'Ja' } }); + expect(format()).toBe('Ja'); + }); + + it('says why a field is hidden, the English as it was', () => { + const note = { label: 'A', conditions: 'b is set', invalidReason: 'too long.' } as never; + expect(hiddenFieldNoteText(note)).toBe( + '"A" is hidden until it applies (b is set). Its value is kept, but it is not valid: too long.' + ); + setReqraftLabels({ formEngine: { hiddenUntilItApplies: (label) => `${label} ist versteckt` } }); + expect(hiddenFieldNoteText(note)).toBe('A ist versteckt'); + }); + + it('counts in the host’s words', () => { + expect(formatCodeLines(1)).toBe('1 line'); + expect(formatCodeLines(2)).toBe('2 lines'); + setReqraftLabels({ formEngine: { lineCount: (count) => `${count} Zeilen` } }); + expect(formatCodeLines(2)).toBe('2 Zeilen'); + }); + + it('joins alternative dependencies in the host’s words', () => { + const schema = { a: { display_name: 'A' }, b: { display_name: 'B' } } as never; + expect(describeDependencies([['a', 'b']], schema)).toBe('("A" or "B")'); + setReqraftLabels({ formEngine: { orJoin: (alternatives) => alternatives.join(' oder ') } }); + expect(describeDependencies([['a', 'b']], schema)).toBe('"A" oder "B"'); + }); + + it('draws its toolbar words again when the language changes', () => { + const Words = () => {useReqraftLabels().formEngine.filterFields}; + render( + + + + ); + expect(screen.getByText('Filter fields...')).toBeTruthy(); + act(() => setReqraftLabels({ formEngine: { filterFields: 'Felder filtern' } })); + expect(screen.getByText('Felder filtern')).toBeTruthy(); + }); +}); + diff --git a/__tests__/reqraftLabelsDrawn.test.tsx b/__tests__/reqraftLabelsDrawn.test.tsx new file mode 100644 index 00000000..a02d0c26 --- /dev/null +++ b/__tests__/reqraftLabelsDrawn.test.tsx @@ -0,0 +1,45 @@ +// Copyright 2026 Qore Technologies, s.r.o. +/* What is on the page says a host's words, and says others when its language changes, with nothing else redrawing it. */ +import { ReqoreUIProvider } from '@qoretechnologies/reqore'; +import { act, render, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { FormEngine } from '../src/components/form/engine/FormEngine'; +import { FetchContext } from '../src/contexts/FetchContext'; +import { setReqraftLabels } from '../src/helpers/reqraftLabels'; +import { emptyFetchContext } from './support/fetchContext'; + +afterEach(() => { + setReqraftLabels(); +}); + +const form = () => + render( + + + + + + ); + +describe('a form', () => { + it('says the English it always did when a host gives nothing', async () => { + form(); + await waitFor(() => expect(document.body.textContent).toContain('Yes')); + }); + + it('says a host’s words, and again when the language changes', async () => { + setReqraftLabels({ formEngine: { yes: 'Ja' } }); + form(); + await waitFor(() => expect(document.body.textContent).toContain('Ja')); + + act(() => setReqraftLabels({ formEngine: { yes: 'Oui' } })); + await waitFor(() => expect(document.body.textContent).toContain('Oui')); + expect(document.body.textContent).not.toContain('Ja'); + }); +}); diff --git a/__tests__/templateBrowserKeyAction.test.tsx b/__tests__/templateBrowserKeyAction.test.tsx index 884ddeb0..00b224f2 100644 --- a/__tests__/templateBrowserKeyAction.test.tsx +++ b/__tests__/templateBrowserKeyAction.test.tsx @@ -1,6 +1,6 @@ // Copyright 2026 Qore Technologies, s.r.o. -import { fireEvent, render } from '@testing-library/react'; -import { describe, expect, it, vi } from 'vitest'; +import { act, fireEvent, render } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; /* The picker is drawn where the dropdown would draw it (see templateBrowserTakesWhole.test.tsx): Reqore's dropdown portal never opens in jsdom. */ @@ -17,6 +17,7 @@ const { TemplateBrowser } = await import('../src/components/form/fields/template const { TemplatePicker } = await import('../src/components/form/fields/template/TemplatePicker'); const { TemplateCatalogueProvider } = await import('../src/helpers/templateCatalogueContext'); const { withTemplateCatalogue } = await import('../src/helpers/templateCatalogue'); +const { setReqraftLabels } = await import('../src/helpers/reqraftLabels'); /** * A control that cannot be typed into (a template-only value, a number's input) writes a key for a context of free @@ -116,3 +117,38 @@ describe('the Key action of a control that cannot be typed into', () => { expect(container.querySelector('.template-picker-key-action')).toBeNull(); }); }); + +describe('the picker in a host’s words', () => { + afterEach(() => { + setReqraftLabels(); + }); + + it('names its groups and actions as the host gives them, and again when the language changes', () => { + setReqraftLabels({ + templateTree: { templateContexts: 'Vorlagenkontexte' }, + templatePicker: { writeKey: 'Schlüssel' }, + }); + const { container } = render( + + + + + + ); + const row = (label: string) => + container.querySelector(`.template-picker-row[data-label="${label}"]`); + expect(row('Vorlagenkontexte')).toBeTruthy(); + act(() => setReqraftLabels({ templateTree: { templateContexts: 'Contextes' } })); + expect(row('Vorlagenkontexte')).toBeNull(); + expect(row('Contextes')).toBeTruthy(); + }); + + it('prefers the words the provider gives over the host’s', () => { + setReqraftLabels({ templatePicker: { writeKey: 'Host' } }); + const { keyAction } = renderBrowser({ writeKey: 'Provider' }); + expect(keyAction('$dynamic:*')?.textContent).toContain('Provider'); + }); +}); diff --git a/design/IDE_INTEGRATION.md b/design/IDE_INTEGRATION.md index fd5b5a78..1672faf4 100644 --- a/design/IDE_INTEGRATION.md +++ b/design/IDE_INTEGRATION.md @@ -98,3 +98,12 @@ checklist the IDE-integration PR ticks off so that can't happen. `useQorusStorage` exactly), so it's ~1 day if wanted — but the seam above already keeps the IDE whole, so native is a scope question, not a blocker. + +## A host's language + +`setReqraftLabels()` gives Reqraft's words in the host's language: the template list (`buildTemplates`), tree and picker, a template field's controls and value tabs, the editors (DPQL and the rest) and FormEngine. What it gives is merged over `DEFAULT_REQRAFT_LABELS`, part by part and word by word, so a host that gives nothing has the English it always had; each call replaces what the last one gave. + +It is one setting for the page, not a prop, because the words are read where no component is: the trees and completions built outside React, and lists kept between forms. A component reads `useReqraftLabels()` and is drawn again when a host gives others; code outside a component reads `reqraftLabels()`, and a hook that memoizes what it builds lists the words among its dependencies. A list kept in `TemplatesCache` is built again from what the server said, without asking again. + +A word a component or provider is given itself (`buildTemplates`' `labels`, `TemplatePicker`'s `labels`, `TemplateCatalogueProvider`'s `pickerLabels`, `valueTabsLabels`, `compactBoxLabels`) comes before the host's. Code does not compare a word to decide what to do: a row says what it is with a flag (`isTemplateContext`), and the label is only drawn. + diff --git a/package.json b/package.json index e4a20ae6..e5351a94 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@qoretechnologies/reqraft", - "version": "0.10.72", + "version": "0.10.74", "description": "ReQraft is a collection of React components and hooks that are used across Qore Technologies' products made using the ReQore component library from Qore.", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/src/components/codeSize/index.tsx b/src/components/codeSize/index.tsx index 1479f17f..90acb111 100644 --- a/src/components/codeSize/index.tsx +++ b/src/components/codeSize/index.tsx @@ -8,6 +8,7 @@ // there is one renderer rather than one per caller, each drifting in // pluralisation, icon and intent. +import { reqraftLabels } from '../../helpers/reqraftLabels'; import { ReqoreTag } from '@qoretechnologies/reqore'; import { IReqoreTagProps } from '@qoretechnologies/reqore/dist/components/Tag'; import { memo } from 'react'; @@ -33,12 +34,12 @@ export function describeCodeSize(code: string): IReqraftCodeSize { /** `4 lines` / `1 line` — the label half of the chip. */ export function formatCodeLines(lines: number): string { - return `${lines} ${lines === 1 ? 'line' : 'lines'}`; + return reqraftLabels().formEngine.lineCount(lines); } /** `92 chars` / `1 char` — the key half of the chip. */ export function formatCodeChars(chars: number): string { - return `${chars} ${chars === 1 ? 'char' : 'chars'}`; + return reqraftLabels().formEngine.charCount(chars); } /** diff --git a/src/components/dpqlEditor/DpqlEditor.tsx b/src/components/dpqlEditor/DpqlEditor.tsx index 95cf0939..09c940ca 100644 --- a/src/components/dpqlEditor/DpqlEditor.tsx +++ b/src/components/dpqlEditor/DpqlEditor.tsx @@ -13,7 +13,16 @@ // a picker beside the text browses them as their catalogue nests them // (`TemplateBrowser`), as a Qog field's are. -import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'; +import { useReqraftLabels } from '../../helpers/reqraftLabels'; +import { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, +} from 'react'; import { SmartEditor } from '../smartEditor/SmartEditor'; import { ISlateConverter, ISlateElement } from '../smartEditor/types'; import { dpqlReferencesIn, dpqlSlateConverter, richtextResponseToSlate } from './dpqlHelpers'; @@ -23,7 +32,6 @@ import { dpqlCompletionsWith, dpqlEmptyCompletionMessage, dpqlExpectsValue, - NO_VALUE_AT_THE_END, presentDpqlDiagnostics, } from './dpqlCompletions'; import { IDpqlEditorProps, IDpqlEditorRef } from './types'; @@ -87,6 +95,7 @@ export const DpqlEditor = forwardRef( }, ref ) => { + const editorWords = useReqraftLabels().editor; // where the field is: its own, or where the host says its fields are (`TemplateCatalogueProvider`) const hostFieldContext = useTemplateFieldContext(); const fieldContext = ownFieldContext ?? hostFieldContext; @@ -258,7 +267,7 @@ export const DpqlEditor = forwardRef( catalogue, true ), - [templates, dpql.fieldMeta, catalogue] + [templates, dpql.fieldMeta, catalogue, editorWords] ); /* A reference that names none of the field's templates says so where it is written, and stays the text it was typed as (qorus#646, David's review: a half-typed reference was taken as it was). */ @@ -347,9 +356,9 @@ export const DpqlEditor = forwardRef( const templateButton = useMemo( () => browseTemplates && !readOnly && templates?.items?.length ? - { insertableAt: dpqlExpectsValue, notInsertable: NO_VALUE_AT_THE_END } + { insertableAt: dpqlExpectsValue, notInsertable: editorWords.noValueAtTheEnd } : undefined, - [browseTemplates, readOnly, templates] + [browseTemplates, readOnly, templates, editorWords] ); const editor = ( diff --git a/src/components/dpqlEditor/dpqlCompletions.ts b/src/components/dpqlEditor/dpqlCompletions.ts index 803f4604..86545a12 100644 --- a/src/components/dpqlEditor/dpqlCompletions.ts +++ b/src/components/dpqlEditor/dpqlCompletions.ts @@ -2,6 +2,7 @@ // What a DPQL editor's one completion list offers, and what it says (qorus#646, David's decision): the server's // completion at the caret, with the field's templates merged in where a value can be written - opened by a click, // a tap or Tab as by typing - and the server's diagnostics in the words the editor owes the author. +import { DEFAULT_REQRAFT_LABELS, reqraftLabels } from '../../helpers/reqraftLabels'; import { TTypeLabel } from '../../helpers/qorusTypeCatalogue'; import { ITemplateNode, @@ -56,8 +57,7 @@ const NO_VALUE_KINDS = new Set([14, 24]); const FIELD_BEING_ASKED = /@[\p{L}\p{M}\p{N}_.]*$/u; /** Why the templates beside the text cannot be used: a value at its end would follow another (qorus#646). */ -export const NO_VALUE_AT_THE_END = - 'A template goes where a value can: after an operator, an opening bracket or a comma, or in an empty expression.'; +export const NO_VALUE_AT_THE_END = DEFAULT_REQRAFT_LABELS.editor.noValueAtTheEnd; /** * The text with `template` put in at its end, where a template chosen from the picker beside the text goes - the @@ -155,9 +155,10 @@ const markedServerItem = (item: ILspCompletionItem): ILspCompletionItem => { data: { ...said, continues: true, - kindLabel: 'Template context', + kindLabel: reqraftLabels().editor.templateContext, + isTemplateContext: true, // listed with the field's own templates and contexts, in one section - section: 'Templates', + section: reqraftLabels().editor.templatesSection, takesWhole: whole, wholeValue: whole ? wholeContextValue(written) : undefined, }, @@ -229,9 +230,9 @@ export const dpqlCompletionsWith = .map((item) => [item.insertText ?? '', !!(item.data as { takesWhole?: boolean } | undefined)?.takesWhole]) ); const oursListed = ours.map((item) => { - const data = item.data as { kindLabel?: string; takesWhole?: boolean } | undefined; + const data = item.data as { isTemplateContext?: boolean; takesWhole?: boolean } | undefined; const whole = serverWhole.get(item.insertText ?? ''); - return data?.kindLabel === 'Template context' && whole !== undefined && whole !== data.takesWhole ? + return data?.isTemplateContext && whole !== undefined && whole !== data.takesWhole ? { ...item, data: { ...data, takesWhole: whole } } : item; }); @@ -288,7 +289,7 @@ export const dpqlEmptyCompletionMessage = ( ): string | undefined => { const before = plainText.slice(0, offset); if (asksForAField(before)) { - return 'No record fields here'; + return reqraftLabels().editor.noRecordFields; } // a context written: what to write in it; a `$` alone or a context's name being written: that there is none const inContext = unlistedContextMessage(before); @@ -296,12 +297,15 @@ export const dpqlEmptyCompletionMessage = ( return inContext; } const written = dpqlReferenceBeingWritten(before); - return written && before[before.length - written] === '$' ? 'No templates here' : undefined; -}; + return written && before[before.length - written] === '$' ? + reqraftLabels().editor.noTemplates + : undefined; +}; /** What is said of the placeholders an unfinished expression is written with, for each part not chosen yet. */ -export const UNFINISHED_PARTS_MESSAGE = `Fill in each ${EXPRESSION_HOLE} (the parts not chosen yet).`; +export const UNFINISHED_PARTS_MESSAGE = + DEFAULT_REQRAFT_LABELS.editor.unfinishedParts(EXPRESSION_HOLE); /** * The server's diagnostics as a DPQL editor says them. The Text view writes an unfinished expression with a @@ -326,7 +330,7 @@ export const presentDpqlDiagnostics = ( range: placeholders[0].range, severity: 2, source: placeholders[0].source, - message: UNFINISHED_PARTS_MESSAGE, + message: reqraftLabels().editor.unfinishedParts(EXPRESSION_HOLE), }, ...diagnostics.filter((diagnostic) => !atPlaceholder(diagnostic)), ]; diff --git a/src/components/dpqlEditor/useDpqlSession.ts b/src/components/dpqlEditor/useDpqlSession.ts index 47a4f016..b1b6247a 100644 --- a/src/components/dpqlEditor/useDpqlSession.ts +++ b/src/components/dpqlEditor/useDpqlSession.ts @@ -5,6 +5,7 @@ // returns for tooltip rendering. Wraps the `dpql/parse` / `dpql/serialize` // / `dpql/validate` custom methods as typed Promise-returning functions. +import { reqraftLabels } from '../../helpers/reqraftLabels'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { ILspDiagnostic } from '../../utils/lspClient.types'; import { useLspSession, IUseLspSessionResult } from '../smartEditor/useLspSession'; @@ -434,7 +435,7 @@ export function useDpqlSession( errors?: Array<{ message: string }>; }> => { if (!session.client) { - return { valid: false, errors: [{ message: 'No LSP connection' }] }; + return { valid: false, errors: [{ message: reqraftLabels().editor.noLspConnection }] }; } try { const result = await session.client.customRequest<{ @@ -449,7 +450,7 @@ export function useDpqlSession( } return { valid: true }; } catch { - return { valid: false, errors: [{ message: 'Validation request failed' }] }; + return { valid: false, errors: [{ message: reqraftLabels().editor.validationFailed }] }; } }, [session.client, session.uri]); diff --git a/src/components/form/engine/CompactRow.tsx b/src/components/form/engine/CompactRow.tsx index fc4b43b2..a302caed 100644 --- a/src/components/form/engine/CompactRow.tsx +++ b/src/components/form/engine/CompactRow.tsx @@ -1,3 +1,4 @@ +import { useReqraftLabels } from '../../../helpers/reqraftLabels'; import { ReqoreButton, ReqoreCollapsibleContent, @@ -39,7 +40,6 @@ import { CompactRowContext } from './compactRowContext'; import { optionTypeSentence, optionTypeWords } from '../../../helpers/qorusTypeCatalogue'; import { useTypeName, useTypeSentenceForm } from '../typeSentenceForm'; import { resolveInheritProps } from './inheritProps'; -import { NO_OPTION_DESCRIPTION } from '../../../helpers/noDescription'; import { revealRow } from './revealRow'; import { MONO_FONT_STACK, @@ -199,25 +199,28 @@ const SUMMARY_TEXT_STYLE: React.CSSProperties = Object.freeze({ * without opening the row it is in. Reached with Tab, it says where it goes and that it opens a new tab, and * shows its focus ring (`.options-readfirst-link`); its address is copied as a link's is. */ -const WebLink = memo(({ address, color }: { address: string; color?: string }) => ( - event.stopPropagation()} - onKeyDown={(event) => { - if (event.key === 'Enter' || event.key === ' ') { - event.stopPropagation(); - } - }} - > - {address} - -)); +const WebLink = memo(({ address, color }: { address: string; color?: string }) => { + const feWords = useReqraftLabels().formEngine; + return ( + event.stopPropagation()} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.stopPropagation(); + } + }} + > + {address} + + ); +}); WebLink.displayName = 'WebLink'; /** The status boxes by name, as their headers say them. */ @@ -250,6 +253,7 @@ export const CompactRow = memo( clusterFirst?: boolean; clusterLast?: boolean; }) => { + const feWords = useReqraftLabels().formEngine; const readOnly = useContextSelector(CompactRowContext, (v) => v.readOnly); const commitMode = useContextSelector(CompactRowContext, (v) => v.commitMode); const options = useContextSelector(CompactRowContext, (v) => v.options); @@ -423,7 +427,7 @@ export const CompactRow = memo( rule, which reqore has no prop for, stays inline. */ effect={{ opacity: 0.7 }} style={{ flexShrink: 0 }} - tooltip='This value is an expression' + tooltip={feWords.valueIsExpression} /> {rendered} @@ -871,10 +875,10 @@ export const CompactRow = memo( intent={groupResolved ? 'success' : 'warning'} icon={groupResolved ? 'CheckLine' : 'LinkM'} label={ - !groupResolved ? 'One of' + !groupResolved ? feWords.oneOf : coveredByLabel ? - `Covered by “${coveredByLabel}”` - : 'Covers' + feWords.coveredBy(coveredByLabel) + : feWords.covers } filterable={ chipGroups.reduce((n, g) => n + requiredGroupsInfo.members[g].length, 0) > 6 @@ -1214,7 +1218,7 @@ export const CompactRow = memo( flat minimal icon='HistoryLine' - tooltip='Revert changes' + tooltip={feWords.revertChanges} onClick={(e: React.MouseEvent) => { e.stopPropagation(); handleValueChange( @@ -1246,9 +1250,9 @@ export const CompactRow = memo( const confirmClearValue = (event?: React.MouseEvent) => { event?.stopPropagation(); confirmAction({ - title: 'Clear value', - description: `Clear the current value of "${label}"?`, - confirmLabel: 'Clear value', + title: feWords.clearValue, + description: feWords.clearValueConfirm(label), + confirmLabel: feWords.clearValue, confirmButtonIntent: 'danger', intent: 'danger', onConfirm: () => handleValueChange(optionName, undefined), @@ -1263,8 +1267,8 @@ export const CompactRow = memo( minimal icon='DeleteBinLine' intent='danger' - aria-label='Clear value' - tooltip='Clear value' + aria-label={feWords.clearValue} + tooltip={feWords.clearValue} onClick={confirmClearValue} /> : null; @@ -1274,7 +1278,7 @@ export const CompactRow = memo( commitMode === 'batched' && changed ? { event.stopPropagation(); cancelEdit(); @@ -1333,8 +1337,8 @@ export const CompactRow = memo( minimal={isPassiveCloseAction} fixed icon={isPassiveCloseAction ? 'CloseLine' : 'CheckLine'} - aria-label={isPassiveCloseAction ? 'Close field' : 'Done'} - tooltip={isPassiveCloseAction ? 'Close field' : 'Done'} + aria-label={isPassiveCloseAction ? feWords.closeField : feWords.done} + tooltip={isPassiveCloseAction ? feWords.closeField : feWords.done} onClick={() => { // what was typed inside the field's pause reaches the form before the row closes pendingEdits.flush(); @@ -1393,8 +1397,12 @@ export const CompactRow = memo( [ { // the default's description is the server's markdown: its words, not its marks - content: - `Default: ${schema.default_value_display_name || ''} — ${markdownToProse(schema.default_value_desc)}`.trim(), + content: feWords + .defaultIs( + schema.default_value_display_name || '', + markdownToProse(schema.default_value_desc) + ) + .trim(), }, ] : []), @@ -1431,8 +1439,8 @@ export const CompactRow = memo( // what the option is, or, where it has no description at all, why nothing says so (qorus#646) const labelHover = [ - schema?.short_desc || (!schema?.desc && typeSentence ? NO_OPTION_DESCRIPTION : undefined), - typeSentence && `Takes ${typeSentence}.`, + schema?.short_desc || (!schema?.desc && typeSentence ? feWords.noDescription : undefined), + typeSentence && feWords.takes(typeSentence), ] .filter(Boolean) .join('\n') || undefined; @@ -1451,8 +1459,8 @@ export const CompactRow = memo( flat fixed className='options-readfirst-help' - aria-label={`Help: ${label}`} - tooltip='Help' + aria-label={feWords.helpOn(label)} + tooltip={feWords.help} effect={{ opacity: rowInks.helpOpacity }} onClick={(event: React.MouseEvent) => { event.stopPropagation(); @@ -1563,11 +1571,11 @@ export const CompactRow = memo( minimal fixed size='small' - tooltip='More actions' + tooltip={feWords.moreActions} items={[ { // a card opened to read is viewed in full, not edited - label: readOnly ? 'View fullscreen' : 'Edit fullscreen', + label: readOnly ? feWords.viewFullscreen : feWords.editFullscreen, icon: 'FullscreenLine', className: 'options-readfirst-fullscreen', onClick: () => setFocusedEditing(optionName), @@ -1575,12 +1583,12 @@ export const CompactRow = memo( ...(removable && !hidden ? [ { - label: 'Remove field', + label: feWords.removeField, icon: 'DeleteBinLine', intent: 'danger', onClick: () => confirmAction({ - title: 'Remove field', + title: feWords.removeField, onConfirm: () => removeSelectedOption(optionName), }), } as IReqoreDropdownItem, @@ -1686,7 +1694,7 @@ export const CompactRow = memo( : null} @@ -2022,7 +2030,7 @@ export const CompactRow = memo( onClose={() => setFocusedEditing(undefined)} description={(schema?.display_name as string) || optionName} // a card opened to read is viewed in full, not edited - {...(readOnly ? { label: 'Focused View' } : {})} + {...(readOnly ? { label: feWords.focusedView } : {})} > {focusedEditing === optionName ? [ ...(Array.isArray(entry) ? - [{ divider: true, label: 'any of:', dividerAlign: 'left' } as IReqoreDropdownItem] + [ + { + divider: true, + label: feWords.anyOf, + dividerAlign: 'left', + } as IReqoreDropdownItem, + ] : []), ...(Array.isArray(entry) ? entry : [entry]).flatMap( (dep): IReqoreDropdownItem[] => { @@ -2472,9 +2490,9 @@ export const CompactRow = memo( role={actsOnClick ? 'button' : undefined} tabIndex={actsOnClick ? 0 : undefined} aria-label={ - readOnlyIsAWayIn ? `Edit ${label}` + readOnlyIsAWayIn ? feWords.edit(label) : readOnlyOpens ? - `View ${label}` + feWords.view(label) : readOnly ? undefined : label @@ -2887,7 +2905,7 @@ export const CompactRow = memo( minimal fixed size='tiny' - tooltip='Field actions' + tooltip={feWords.fieldActions} items={injectedOptionActionMenuItems} /> @@ -2904,7 +2922,7 @@ export const CompactRow = memo( minimal compact icon='HistoryLine' - tooltip='Revert changes' + tooltip={feWords.revertChanges} onClick={(e: React.MouseEvent) => { e.stopPropagation(); handleValueChange( diff --git a/src/components/form/engine/CompactToolbar.tsx b/src/components/form/engine/CompactToolbar.tsx index 5d86de02..b0b10fb2 100644 --- a/src/components/form/engine/CompactToolbar.tsx +++ b/src/components/form/engine/CompactToolbar.tsx @@ -1,3 +1,4 @@ +import { IFormEngineLabels, useReqraftLabels } from '../../../helpers/reqraftLabels'; import { ReqoreButton, ReqoreControlGroup, @@ -18,12 +19,14 @@ import { CompactToolbarContext, TCompactSort } from './compactToolbarContext'; // Field sort modes, listed in the Fields menu's "Sort by" submenu. 'schema' is // the default (the schema's declared order); the rest reorder within each group. -const SORT_MODES: { value: TCompactSort; label: string; tooltip: string }[] = [ - { value: 'schema', label: 'Default order', tooltip: 'The schema’s declared field order' }, - { value: 'alpha', label: 'Name A→Z', tooltip: 'Sort fields by name, ascending' }, - { value: 'alpha-desc', label: 'Name Z→A', tooltip: 'Sort fields by name, descending' }, - { value: 'unset', label: 'Unset first', tooltip: 'Fields without a value first' }, - { value: 'invalid', label: 'Invalid first', tooltip: 'Fields needing attention first' }, +const sortModes = ( + words: IFormEngineLabels +): { value: TCompactSort; label: string; tooltip: string }[] => [ + { value: 'schema', label: words.sortDefault, tooltip: words.sortDefaultTooltip }, + { value: 'alpha', label: words.sortAlpha, tooltip: words.sortAlphaTooltip }, + { value: 'alpha-desc', label: words.sortAlphaDesc, tooltip: words.sortAlphaDescTooltip }, + { value: 'unset', label: words.sortUnset, tooltip: words.sortUnsetTooltip }, + { value: 'invalid', label: words.sortInvalid, tooltip: words.sortInvalidTooltip }, ]; // "Focus" header: a summary line ({pct}% complete · {set}/{total} set · @@ -109,6 +112,7 @@ const StyledPct = styled.span` * `CompactToolbarContext`. */ export const CompactToolbar = memo((reqoreProps: Partial) => { + const feWords = useReqraftLabels().formEngine; const { parts, readOnly, @@ -180,7 +184,7 @@ export const CompactToolbar = memo((reqoreProps: Partial · {completion.set}/{completion.total} set @@ -192,7 +196,7 @@ export const CompactToolbar = memo((reqoreProps: Partial - {attentionCount} need attention → + {feWords.needAttention(attentionCount)} : null} {completion.pct}% @@ -222,7 +226,7 @@ export const CompactToolbar = memo((reqoreProps: Partial item.value && onAddOptionalField(item.value) } items={ [ { - label: 'Required only', + label: feWords.requiredOnly, selected: requiredOnly, icon: requiredOnly ? 'CheckboxCircleLine' : 'CheckboxBlankCircleLine', - tooltip: 'Show only required fields', + tooltip: feWords.requiredOnlyTooltip, onClick: () => setRequiredOnly((value) => !value), }, { - label: 'Show field types', + label: feWords.showFieldTypes, selected: showFieldTypes, icon: showFieldTypes ? 'CheckboxCircleLine' : 'CheckboxBlankCircleLine', - tooltip: 'Annotate each field with its type', + tooltip: feWords.showFieldTypesTooltip, onClick: onToggleFieldTypes, }, // Sort by — a submenu (collapsed by default) so the five modes @@ -265,14 +269,14 @@ export const CompactToolbar = memo((reqoreProps: Partial mode.value === compactSort)?.label + sortModes(feWords).find((mode) => mode.value === compactSort)?.label : undefined, - items: SORT_MODES.map((mode) => ({ + items: sortModes(feWords).map((mode) => ({ label: mode.label, tooltip: mode.tooltip, selected: compactSort === mode.value, @@ -284,22 +288,22 @@ export const CompactToolbar = memo((reqoreProps: Partial diff --git a/src/components/form/engine/FormEngine.tsx b/src/components/form/engine/FormEngine.tsx index 2ce35393..a0fead62 100644 --- a/src/components/form/engine/FormEngine.tsx +++ b/src/components/form/engine/FormEngine.tsx @@ -1,3 +1,4 @@ +import { useReqraftLabels } from '../../../helpers/reqraftLabels'; import { ReqoreButton, ReqoreCollection, @@ -1459,6 +1460,7 @@ const FormEngineImpl = ({ skeletonReason, ...rest }: IFormEngineProps) => { + const feWords = useReqraftLabels().formEngine; // Built-ins + whatever the consumer declared for its own injected editors. const isRendererOnly = useMemo( () => createRendererOnlyUiTypeCheck(rendererOnlyUiTypes), @@ -1931,7 +1933,7 @@ const FormEngineImpl = ({ return; } setOptionsError( - error instanceof Error ? error.message : String(error ?? 'Failed to load options') + error instanceof Error ? error.message : String(error ?? feWords.failedToLoadOptions) ); setOptionsLoading(false); }); @@ -1939,7 +1941,7 @@ const FormEngineImpl = ({ return () => { cancelled = true; }; - }, [optionsLoader]); + }, [optionsLoader, feWords]); // --- Remote schema fetching, ported from IDE Options (systemOptions.tsx // lines 394-462 + 504-522). reqraft's `query()` replaces the IDE's @@ -2360,8 +2362,7 @@ const FormEngineImpl = ({ intent: 'info', size: 'tiny', tooltip: { - content: - 'Other options depend on this option, changing it may result in configuration changes.', + content: feWords.dependentOptionsWarning, delay: 300, }, }); @@ -2369,7 +2370,7 @@ const FormEngineImpl = ({ return badges; }, - [options] + [options, feWords] ); const fixedValue: TQorusForm = (localValue.fields || {}) as TQorusForm; @@ -4183,14 +4184,19 @@ const FormEngineImpl = ({ }> = [ { key: 'attention', - label: compactBoxLabels?.attention ?? 'Needs attention', + label: compactBoxLabels?.attention ?? feWords.boxAttention, intent: 'warning', icon: 'ErrorWarningLine', }, - { key: 'set', label: compactBoxLabels?.set ?? 'Set', intent: 'success', icon: 'CheckLine' }, + { + key: 'set', + label: compactBoxLabels?.set ?? feWords.boxSet, + intent: 'success', + icon: 'CheckLine', + }, { key: 'optional', - label: compactBoxLabels?.optional ?? 'Optional', + label: compactBoxLabels?.optional ?? feWords.boxOptional, icon: 'CheckboxBlankCircleLine', }, ]; @@ -4265,7 +4271,7 @@ const FormEngineImpl = ({ {/* the words and the mark in a shade of the warning that reads on the cluster's tint */} - One of the below is required + {feWords.oneOfRequired} {railed} @@ -4426,9 +4432,7 @@ const FormEngineImpl = ({ {size(groupKeys) === 0 || (readOnly && !bucketCount('set')) ? - {readOnly && !query ? - 'Nothing is set.' - : 'No fields match the current filters.'} + {readOnly && !query ? feWords.nothingSet : feWords.noFieldsMatch} : null} @@ -4629,9 +4633,8 @@ const FormEngineImpl = ({ > {moreOpen ? - 'Show fewer' - : `${deferredCount} more ${box.key === 'optional' ? 'optional ' : ''}field${deferredCount === 1 ? '' : 's'}` - } + feWords.showFewer + : feWords.moreFields(deferredCount, box.key === 'optional')} {/* Why Save refuses, as text: a disabled button fires no hover, and a phone has none. */} {validityData.isValid ? null : ( @@ -4697,13 +4700,11 @@ const FormEngineImpl = ({ className='options-readfirst-save' disabled={!validityData.isValid} tooltip={ - validityData.isValid ? - 'Apply the staged changes' - : 'Resolve the invalid fields before saving' + validityData.isValid ? feWords.applyChanges : feWords.resolveInvalid } onClick={handleCommitClick} > - Save + {feWords.save} - Discard + {feWords.discard} @@ -4764,7 +4765,7 @@ const FormEngineImpl = ({ if (!options || !size(options)) { return ( - No options available + {feWords.noOptions} ); } @@ -4784,9 +4785,7 @@ const FormEngineImpl = ({ )} {recordRequiresSearchOptions && !readOnly ? <> - - This provider record requires some search options to be set. You can set them below. - + {feWords.searchOptionsRequired} : null} @@ -4816,15 +4815,14 @@ const FormEngineImpl = ({ onClick={() => setShowInvalidOptionsOnly(!showInvalidOptionsOnly)} > {showInvalidOptionsOnly ? - 'Showing invalid fields only. Click here again to show all fields.' - : `${size(validityData.invalidFields) < 2 ? 'A field is not valid and requires' : `${size(validityData.invalidFields)} fields are not valid and require`} attention. Click here to only show invalid fields.` - } + feWords.showingInvalidOnly + : feWords.invalidFields(size(validityData.invalidFields))} : null} {unavailableOptionsCount.current ? <> - {`${unavailableOptionsCount.current} ${unavailableOptionsCount.current === 1 ? 'field' : 'fields'} hidden because they are not supported on the current instance`} + {feWords.fieldsNotSupported(unavailableOptionsCount.current)} @@ -4838,7 +4836,7 @@ const FormEngineImpl = ({ handleAddOptionalFieldChange('options', val)} - placeholder={placeholder || 'More Options Available'} + placeholder={placeholder || feWords.moreOptions} minimal icon='ListView' showDescription={false} @@ -4877,7 +4875,7 @@ const FormEngineImpl = ({ delay: 300, content: ( ), @@ -4938,14 +4936,14 @@ const FormEngineImpl = ({ type !== 'code-editor' && ((options[optionName] as any)?.ui_type || options[optionName]?.type) !== 'code-editor' && { - label: 'Focused editing', + label: feWords.focusedEditing, icon: 'FullscreenLine', className: 'options-item-fullscreen', onClick: () => setFocusedEditing(optionName), }, !readOnly && hasOptionChanged((other as any).value, optionName) && { - label: 'Revert changes', + label: feWords.revertChanges, icon: 'HistoryLine', className: 'options-item-revert', onClick: () => { @@ -4959,7 +4957,7 @@ const FormEngineImpl = ({ (other as any).value && !isEqual((other as any).value, getDefaultValue(options[optionName])) && !(options[optionName]?.disabled || (options[optionName] as any)?.readonly) && { - label: 'Remove value', + label: feWords.removeValue, icon: 'CloseLine', className: 'options-item-remove', onClick: () => handleValueChange(optionName, undefined), @@ -4967,13 +4965,13 @@ const FormEngineImpl = ({ !readOnly && !options[optionName]?.preselected && !options[optionName]?.required && { - label: 'Remove option', + label: feWords.removeOption, icon: 'DeleteBinLine', intent: 'danger', className: 'options-optional-remove', onClick: () => { confirmAction({ - title: 'Remove Selected Option', + title: feWords.removeSelectedOption, onConfirm: () => { removeSelectedOption(optionName); }, @@ -5016,7 +5014,7 @@ const FormEngineImpl = ({ { icon: 'HistoryLine', className: 'fields-revert', - tooltip: 'Revert Changes', + tooltip: feWords.revertChangesTitle, minimal: true, position: 'right', show: @@ -5030,7 +5028,7 @@ const FormEngineImpl = ({ icon: 'CodeLine', minimal: true, className: 'fields-show-types', - tooltip: showFieldTypes ? 'Hide field types' : 'Show field types', + tooltip: showFieldTypes ? feWords.hideFieldTypes : feWords.showFieldTypes, intent: showFieldTypes ? 'info' : undefined, show: showTypeToggle, position: 'right', diff --git a/src/components/form/engine/HiddenFieldsNote.tsx b/src/components/form/engine/HiddenFieldsNote.tsx index cf2b5eca..afacc7bc 100644 --- a/src/components/form/engine/HiddenFieldsNote.tsx +++ b/src/components/form/engine/HiddenFieldsNote.tsx @@ -1,4 +1,5 @@ // Copyright 2026 Qore Technologies, s.r.o. +import { reqraftLabels } from '../../../helpers/reqraftLabels'; import { ReqoreMessage } from '@qoretechnologies/reqore'; import { memo } from 'react'; @@ -24,11 +25,11 @@ export const hiddenFieldNoteText = ({ conditions, invalidReason, }: IHiddenFieldWithValue): string => - `"${label}" is hidden until it applies (${conditions}). Its value is kept${ - invalidReason ? - `, but it is not valid: ${invalidReason.replace(/\.$/, '')}` - : ', and not used until then' - }.`; + reqraftLabels().formEngine.hiddenUntilItApplies( + label, + conditions, + invalidReason?.replace(/\.$/, '') + ); export interface IHiddenFieldsNoteProps { fields: IHiddenFieldWithValue[]; diff --git a/src/components/form/engine/OptionFieldMessages.tsx b/src/components/form/engine/OptionFieldMessages.tsx index 8224a602..814dc56a 100644 --- a/src/components/form/engine/OptionFieldMessages.tsx +++ b/src/components/form/engine/OptionFieldMessages.tsx @@ -1,3 +1,4 @@ +import { reqraftLabels } from '../../../helpers/reqraftLabels'; import { ReqoreControlGroup, ReqoreTag, ReqoreVerticalSpacer } from '@qoretechnologies/reqore'; import { IReqoreTagProps } from '@qoretechnologies/reqore/dist/components/Tag'; import { TReqoreIntent } from '@qoretechnologies/reqore/dist/constants/theme'; @@ -99,6 +100,7 @@ export const getShownSchemaMessages = ( * and the lock cannot disagree about what an entry means. */ const describeDependency = (dependency: string, schema: IQorusFormSchema): string | undefined => { + const feWords = reqraftLabels().formEngine; const { name, op, value } = parseDependency(dependency); const dependencySchema = schema[name]; @@ -119,10 +121,14 @@ const describeDependency = (dependency: string, schema: IQorusFormSchema): strin // being unanswered — so it gets a sentence of its own rather than the // comparison template, which would read `must not be "undefined"`. if (op === '!') { - return `${label} must have no value`; + return feWords.mustHaveNoValue(label); } - return op ? `${label} must ${op === '!=' ? 'not ' : ''}be "${shown}"` : label; + return ( + op === '!=' ? feWords.mustNotBe(label, shown) + : op ? feWords.mustBe(label, shown) + : label + ); }; /** @@ -148,7 +154,9 @@ export const describeDependencies = ( if (!alternatives.length) { return undefined; } - return alternatives.length > 1 ? `(${alternatives.join(' or ')})` : alternatives[0]; + return alternatives.length > 1 ? + reqraftLabels().formEngine.orJoin(alternatives) + : alternatives[0]; }) .filter((entry): entry is string => !!entry) .join(', '); @@ -222,6 +230,7 @@ export const getAllowedValueAvailability = ( allOptions?: TQorusForm, schema?: IQorusFormSchema ): IAllowedValueAvailability => { + const feWords = reqraftLabels().formEngine; if (!value) { return ALLOWED_VALUE_AVAILABLE; } @@ -242,11 +251,11 @@ export const getAllowedValueAvailability = ( intent: 'warning', reason: dependsOn ? - `Unavailable because some dependencies are not fulfilled: ${dependsOn}` + feWords.unavailable(dependsOn) // Nothing nameable: every entry pointed at a field this schema does // not contain. A sentence that ends in a colon and then stops reads // as a rendering fault rather than as a fact about the form. - : 'Unavailable because some dependencies are not fulfilled', + : feWords.unavailable(), }; } @@ -363,6 +372,7 @@ export const getOptionFieldMessages = ({ getType, isRendererOnly, }: IOptionFieldMessagesProps): IReqoreTagProps[] => { + const feWords = reqraftLabels().formEngine; const optionSchema = schema[name]; const result: IReqoreTagProps[] = []; const locked = @@ -412,7 +422,7 @@ export const getOptionFieldMessages = ({ ); if (!validationData.isValid) { result.push({ - label: validationData.reason || 'Field value is not valid', + label: validationData.reason || feWords.valueNotValid, intent: 'danger', }); } @@ -421,7 +431,7 @@ export const getOptionFieldMessages = ({ // locked field is not asked for: the dependency message below says what // unlocks it, and until then there is nothing to fill in. if (optionSchema?.required && !locked) { - result.push({ label: 'This field is required', intent: 'danger' }); + result.push({ label: feWords.required, intent: 'danger' }); } } @@ -450,11 +460,11 @@ export const getOptionFieldMessages = ({ result.push({ label: dependsOn ? - `This field is disabled because some dependencies are not fulfilled: ${dependsOn}` + feWords.disabledByDependencies(dependsOn) // Nothing nameable: every entry pointed at a field this schema does // not contain. A sentence that ends in a colon and then stops reads // as a rendering fault rather than as a fact about the form. - : 'This field is disabled because some dependencies are not fulfilled', + : feWords.disabledByDependencies(), intent: 'warning', }); } diff --git a/src/components/form/engine/OptionsHelpDialog.tsx b/src/components/form/engine/OptionsHelpDialog.tsx index dc81f1b5..59ea7c53 100644 --- a/src/components/form/engine/OptionsHelpDialog.tsx +++ b/src/components/form/engine/OptionsHelpDialog.tsx @@ -1,10 +1,10 @@ +import { useReqraftLabels } from '../../../helpers/reqraftLabels'; import { IReqoreModalProps, ReqoreModal } from '@qoretechnologies/reqore/dist/components/Modal'; import { TQorusFormFieldSchema } from '@qoretechnologies/ts-toolkit'; import { ReqoreP } from '@qoretechnologies/reqore'; import { optionTypeSentence } from '../../../helpers/qorusTypeCatalogue'; import { Description } from '../../Description'; import { AllowedValuesHelp } from '../../Description/AllowedValuesHelp'; -import { NO_OPTION_DESCRIPTION } from '../../../helpers/noDescription'; import { useTypeName, useTypeSentenceForm } from '../typeSentenceForm'; export interface IOptionsHelpDialogProps extends IReqoreModalProps { @@ -12,6 +12,7 @@ export interface IOptionsHelpDialogProps extends IReqoreModalProps { } export const OptionsHelpDialog = ({ option, onClose }: IOptionsHelpDialogProps) => { + const feWords = useReqraftLabels().formEngine; const typeName = useTypeName(); const typeSentenceForm = useTypeSentenceForm(); if (!option) { @@ -25,7 +26,7 @@ export const OptionsHelpDialog = ({ option, onClose }: IOptionsHelpDialogProps) return ( {takes ? - {`Takes ${takes}.`} + {feWords.takes(takes)} : null} {description ? : /* why there is none, never a blank (qorus#646) */ - {NO_OPTION_DESCRIPTION} + {feWords.noDescription} } {/* the values it takes, as help shows allowed values: the exact values, by name, described */} diff --git a/src/components/form/engine/_structuredData/StructuredDataView.tsx b/src/components/form/engine/_structuredData/StructuredDataView.tsx index 0d882c9e..26b7a083 100644 --- a/src/components/form/engine/_structuredData/StructuredDataView.tsx +++ b/src/components/form/engine/_structuredData/StructuredDataView.tsx @@ -4,6 +4,7 @@ * `{type, value}` UI envelope, the embedded YAML/JSON string parser, and the * Qorus date parsing/formatting. Renders flat/minimal so it slots into rows. */ +import { reqraftLabels } from '../../../../helpers/reqraftLabels'; import { ReqoreDataView, type IReqoreDataViewEnvelope } from '@qoretechnologies/reqore'; import { memo } from 'react'; import { @@ -44,7 +45,7 @@ const parseQorusDate = (value: string): string | undefined => qorusDateStringToI export const StructuredDataView = memo( ({ value, - emptyText = 'No structured data.', + emptyText, fitContent, collapsibleRoot = true, showTypes, @@ -53,7 +54,7 @@ export const StructuredDataView = memo( }: IStructuredDataViewProps) => ( IReqorePanelAction[]); + IReqorePanelAction[] | ((context: IOptionActionsContext) => IReqorePanelAction[]); /** * Resolve the `optionActions` seam for one option. @@ -79,7 +79,7 @@ export const optionActionAsMenuItem = ( label: action.label ?? (typeof action.tooltip === 'string' ? action.tooltip : undefined) ?? - `Action ${index + 1}`, + reqraftLabels().formEngine.actionNumber(index + 1), icon: action.icon, intent: action.intent, disabled: action.disabled, @@ -120,7 +120,7 @@ export const optionRowActions = ({ minimal: true, flat: true, className: 'options-item-more', - tooltip: 'More actions', + tooltip: reqraftLabels().formEngine.moreActions, actions: items, }; diff --git a/src/components/form/engine/readFirst.ts b/src/components/form/engine/readFirst.ts index 6fbe2aad..c94ec86a 100644 --- a/src/components/form/engine/readFirst.ts +++ b/src/components/form/engine/readFirst.ts @@ -1,3 +1,4 @@ +import { reqraftLabels } from '../../../helpers/reqraftLabels'; import { markdownToProse } from '../../../helpers/markdownText'; import { IQorusFormField, @@ -19,10 +20,6 @@ import { findAllowedValueOption } from '../../../helpers/allowedValues'; import { isOptionValueEmpty } from '../../../helpers/validations'; import type { IProviderType } from '../../../helpers/providerValue'; -/** "N noun" with naive pluralisation ("1 item", "2 items"). */ -const pluralize = (count: number, noun: string): string => - `${count} ${noun}${count === 1 ? '' : 's'}`; - // The server's typed-value serialization wraps each value in an envelope — // `{ type, value }` plus optional descriptive keys. Only this exact shape is // unwrapped; any foreign key means it's a real user hash, not an envelope. @@ -127,7 +124,7 @@ const formatList = (items: unknown[], schema?: TQorusFormFieldSchema): string => ) .filter((part) => part !== '' && part !== undefined && part !== null); - return parts.length ? parts.join(', ') : pluralize(items.length, 'item'); + return parts.length ? parts.join(', ') : reqraftLabels().formEngine.itemCount(items.length); }; /** "datasource · omquser · /bb_local": the provider's kind, its name and its @@ -218,7 +215,9 @@ export const getDataProviderPreview = (value: unknown): IDataProviderPreview | u /** Summarise a hash/object by its field count ("2 fields"), or "Set" when empty. */ const fieldCountLabel = (obj: object): string => { const keys = Object.keys(obj); - return keys.length ? pluralize(keys.length, 'field') : 'Set'; + return keys.length ? + reqraftLabels().formEngine.fieldCount(keys.length) + : reqraftLabels().formEngine.set; }; /** The object/list editor stores complex values as a serialized YAML string @@ -266,10 +265,7 @@ export const describeBlockedSave = ( const name = schema?.[first.fieldName]?.display_name || first.fieldName; const why = first.validation.reason || first.validation.reasons?.[0]; const more = invalidFields.length - 1; - return ( - `Before saving, fix ${name}${why ? `: ${why}` : ''}` + - (more ? ` (and ${more} more field${more === 1 ? '' : 's'})` : '') - ); + return reqraftLabels().formEngine.beforeSaving(name, why, more); }; /** @@ -505,12 +501,12 @@ export const formatBytes = (bytes: number): string => { /** Read-first summary of a `schema-definition` value: the schema name plus a * table count (e.g. `orders · 2 tables`). */ const formatSchemaDefinition = (value: unknown): string => { - if (!value || typeof value !== 'object') return 'Schema'; + if (!value || typeof value !== 'object') return reqraftLabels().formEngine.schema; const def = value as { schema?: { name?: string }; tables?: object }; const name = def.schema?.name; const count = def.tables ? Object.keys(def.tables).length : 0; - const tables = count ? ` · ${count} table${count === 1 ? '' : 's'}` : ''; - return name ? `${name}${tables}` : 'Schema'; + const tables = count ? ` · ${reqraftLabels().formEngine.tableCount(count)}` : ''; + return name ? `${name}${tables}` : reqraftLabels().formEngine.schema; }; /** @@ -544,12 +540,12 @@ const capSummary = (text: string): string => { return `${body.trimEnd()}…`; }; - - /** * Markdown without its fenced code blocks. A fence opens on a line of its own (up to three spaces in) and closes * on the next such line, or runs to the end when nothing closes it, as markdown reads it. Three backticks inside * a line open no block: they are a code span, and what it holds is summarised as written (qorus#646) - taking + + * every ``` to ``` out dropped the span's text. */ const withoutFencedBlocks = (value: string): string => { @@ -648,7 +644,7 @@ export const formatOptionValue = ( // Offline summary of the {exp,args} AST already in the form value — the // same client-side renderer the editor's "Explain" seam falls back to when // the LSP is unreachable. The drill-in editor shows the canonical DPQL. - return renderExpressionToText(expressionAst) || 'Expression'; + return renderExpressionToText(expressionAst) || reqraftLabels().formEngine.expression; } /* A template is the field it names, whatever the field's type, so it is summarised before any type @@ -666,21 +662,21 @@ export const formatOptionValue = ( // Same reasoning, different notation: show what the markdown says, not how it // is written. if ((schema as { ui_type?: string } | undefined)?.ui_type === 'markdown') { - // `|| 'Set'` is not defensive padding. A description that is ONLY a fenced + // `|| reqraftLabels().formEngine.set` is not defensive padding. A description that is ONLY a fenced // code block summarises to the empty string — the block is dropped, and // there is no prose behind it — and CompactRow reads `formatted === ''` as // "no value" and renders a faint em-dash. A field holding a whole code // block would show as empty. `'Set'` is this file's established answer for // "there is a value here that does not reduce to a line" (colors, files, // richtext all use it), and the block itself still renders in the inset. - return summariseMarkdown(value) || 'Set'; + return summariseMarkdown(value) || reqraftLabels().formEngine.set; } const type = getValueType(option, schema); // a template has been summarised as itself, above: only a value that is true or false reads Yes or No if (type === 'bool' || type === 'boolean') { - return value ? 'Yes' : 'No'; + return value ? reqraftLabels().formEngine.yes : reqraftLabels().formEngine.no; } const allowedLabel = getAllowedValueLabel(value, schema); @@ -705,12 +701,12 @@ export const formatOptionValue = ( } if (type === 'file') { - return formatFileValue(value) ?? 'Set'; + return formatFileValue(value) ?? reqraftLabels().formEngine.set; } if (type === 'richtext') { const text = richtextToString(value as any); - return text && text.trim() ? text : 'Set'; + return text && text.trim() ? text : reqraftLabels().formEngine.set; } // A markdown value reads as prose, not as source: the row's one line — and @@ -719,7 +715,7 @@ export const formatOptionValue = ( // row's inset below. if (type === 'markdown' && typeof value === 'string') { const text = summariseMarkdown(value); - return text || 'Set'; + return text || reqraftLabels().formEngine.set; } if (Array.isArray(value)) { diff --git a/src/components/form/fields/rich-text/RichText.tsx b/src/components/form/fields/rich-text/RichText.tsx index 85c5600b..a3ddd2fc 100644 --- a/src/components/form/fields/rich-text/RichText.tsx +++ b/src/components/form/fields/rich-text/RichText.tsx @@ -1,3 +1,4 @@ +import { useReqraftLabels } from '../../../../helpers/reqraftLabels'; import { IReqoreRichTextEditorProps, ReqoreRichTextEditor, @@ -499,12 +500,13 @@ export const RichTextFormField = memo(({ const hostCatalogue = useTemplateCatalogue(); const templateCatalogue = ownCatalogue ?? hostCatalogue; // with the field's catalogue's contexts, where it has one: typed and picked alike + const treeWords = useReqraftLabels().templateTree; const templateRoots = useMemo( () => allowTemplates !== false && !readOnly ? withTemplateCatalogue(templateTree(templates, { wholeRecords }), templateCatalogue, wholeRecords) : [], - [allowTemplates, readOnly, templates, templateCatalogue, wholeRecords] + [allowTemplates, readOnly, templates, templateCatalogue, wholeRecords, treeWords] ); // a lone group kept whole, for the picker to open only where nothing is added beside it (`openLoneGroup`) const templateRootsKept = useMemo( @@ -516,11 +518,11 @@ export const RichTextFormField = memo(({ wholeRecords ) : [], - [allowTemplates, readOnly, templates, templateCatalogue, wholeRecords] + [allowTemplates, readOnly, templates, templateCatalogue, wholeRecords, treeWords] ); const pickerRoots = useMemo( () => openLoneGroup(withTemplateContexts(templateRootsKept, wholeRecords)), - [templateRootsKept, wholeRecords] + [templateRootsKept, wholeRecords, treeWords] ); const completionMenuId = useMemo(() => `completion-menu-text-${++textCompletionMenuCounter}`, []); // a template's type named in its row by the catalogue the surrounding form has, never its code diff --git a/src/components/form/fields/rich-text/useTemplateTags.ts b/src/components/form/fields/rich-text/useTemplateTags.ts index c31906dc..b40a7d74 100644 --- a/src/components/form/fields/rich-text/useTemplateTags.ts +++ b/src/components/form/fields/rich-text/useTemplateTags.ts @@ -1,4 +1,5 @@ // Copyright 2026 Qore Technologies, s.r.o. +import { useReqraftLabels } from '../../../../helpers/reqraftLabels'; import { IReqoreRichTextEditorProps } from '@qoretechnologies/reqore/dist/components/RichTextEditor'; import { IReqoreFormTemplates } from '@qoretechnologies/reqore/dist/components/Textarea'; import { size } from 'lodash'; @@ -52,6 +53,7 @@ export const useTemplateTags = ( templates: IReqoreFormTemplates | undefined, allowTemplates = true ): IReqoreRichTextEditorProps['tags'] => { + const browserWords = useReqraftLabels().templateBrowser; const renderMarkdown = useMarkdownRenderer(); // a template's type named by the catalogue the surrounding form has, never its code const typeLabel = useTypeLabel(); @@ -61,9 +63,9 @@ export const useTemplateTags = ( if (size(templates?.items) && allowTemplates) { tags.templates = { icon: 'MoneyDollarBoxLine', - label: 'Templates', + label: browserWords.templates, flat: false, - description: 'Universal values that can be used in multiple places', + description: browserWords.universalValues, /* The in-editor list already nests everything under "Templates", so a lone category makes the author drill through TWO headers to reach the only values on offer. Hoist it. */ @@ -83,5 +85,5 @@ export const useTemplateTags = ( } return tags; - }, [allowTemplates, templates, renderMarkdown, typeLabel]); + }, [allowTemplates, templates, renderMarkdown, typeLabel, browserWords]); }; diff --git a/src/components/form/fields/template/TemplateBrowser.tsx b/src/components/form/fields/template/TemplateBrowser.tsx index bf55939c..d90ba20e 100644 --- a/src/components/form/fields/template/TemplateBrowser.tsx +++ b/src/components/form/fields/template/TemplateBrowser.tsx @@ -1,4 +1,5 @@ // Copyright 2026 Qore Technologies, s.r.o. +import { useReqraftLabels } from '../../../../helpers/reqraftLabels'; import { ReqoreDropdown } from '@qoretechnologies/reqore'; import { IReqoreDropdownProps } from '@qoretechnologies/reqore/dist/components/Dropdown'; import { IReqoreFormTemplates } from '@qoretechnologies/reqore/dist/components/Textarea'; @@ -59,6 +60,7 @@ export const TemplateBrowser = ({ // the template contexts too, each taken whole or gone into, as typing offers them (qorus#646) // with the catalogue of the fields here, where the host gave one (`TemplateCatalogueProvider`) const catalogue = useTemplateCatalogue(); + const treeWords = useReqraftLabels().templateTree; const roots = useMemo( () => openLoneGroup( @@ -73,7 +75,7 @@ export const TemplateBrowser = ({ wholeRecords ) ), - [templates, catalogue, wholeRecords] + [templates, catalogue, wholeRecords, treeWords] ); const id = useMemo(() => `template-browser-${++templateBrowserCounter}`, []); // the popover's own controls: a pick puts it away diff --git a/src/components/form/fields/template/TemplateField.tsx b/src/components/form/fields/template/TemplateField.tsx index 5ab8d2aa..800230d1 100644 --- a/src/components/form/fields/template/TemplateField.tsx +++ b/src/components/form/fields/template/TemplateField.tsx @@ -3,6 +3,11 @@ // onChange adapters, the reqraft `useExpressions` signature, dropped // SaveValueButton / useGetAppActionData). Full seam list: // `.tasks/FIELD_STACK_REPORT.md`. +import { + DEFAULT_REQRAFT_LABELS, + IValueTabsLabels, + useReqraftLabels, +} from '../../../../helpers/reqraftLabels'; import { ReqoreButton, ReqoreControlGroup, @@ -279,52 +284,10 @@ export const ComponentMap = { file: FileField, }; -/** - * The words of the value tabs (qorus#646). A host that translates its interface passes its own; a label that - * holds a value is a function of it, so the host keeps its language's word order. - */ -export interface IValueTabsLabels { - value: string; - expression: string; - visual: string; - template: string; - expressionTooltip: string; - visualTooltip: string; - templateTooltip: string; - /** Said on the Value tab for an expression that is not a lone value or template. */ - cannotShowExpression: string; - /** - * The Expression tab's tooltip, in a field that is a condition (`isConditionOption`). The tab keeps its name: - * a condition is written as an expression, and the hover says so (qorus#646, David: "what is "Condition"? Is - * it meant to be an expression?"). - */ - conditionTooltip: string; - /** The Visual tab's tooltip, in a condition field. */ - conditionVisualTooltip: string; - /** Said on the Value tab for a condition that is not a lone value or template. */ - cannotShowCondition: string; - /** Said on the Value tab for a template it cannot show; `tab` is the tab's own label. */ - templateShownOn: (tab: string) => string; - replaceWithAValue: string; - /** A template of another type than the field's; `from` and `to` name the types inside the sentence. */ - conversion: (from: string, to: string) => string; - /** - * The name of a type inside `conversion`'s sentence. Unless a host gives its own, it is the type catalogue's - * sentence form ("a whole number"), in the language the server answered in (qorus#646). - */ - typeName?: (type: string) => string; - undo: string; - /** The undo's tooltip; `text` is what was typed. */ - undoTooltip: (text: string) => string; - /** - * Said on the Value tab while the Expression tab holds text that does not parse: the field keeps the value - * shown here, and the text waits there with its error (qorus#646). - */ - expressionDraftNotUsed?: string; -} +export type { IValueTabsLabels }; /** Why a field that takes templates offers none here: its context's catalogue has none of its type. */ -export const NO_TEMPLATES_ON_OFFER = 'No templates are offered here for this field.'; +export const NO_TEMPLATES_ON_OFFER = DEFAULT_REQRAFT_LABELS.templateField.noTemplatesOnOffer; /** The keyboard a whole number or a number is typed on, on the Value tab. */ const TEXTABLE_INPUT_MODES: Record = { @@ -337,28 +300,7 @@ const TEXTABLE_INPUT_MODES: Record = { softnumber: 'decimal', }; -export const DEFAULT_VALUE_TABS_LABELS: IValueTabsLabels = { - value: 'Value', - expression: 'Expression', - visual: 'Visual', - template: 'Template', - expressionTooltip: 'Write an expression - it also takes templates', - visualTooltip: 'Build the expression visually', - templateTooltip: 'Choose a template', - cannotShowExpression: "This expression can't be shown as a value.", - conditionTooltip: 'Write the condition as an expression', - conditionVisualTooltip: 'Build the condition visually', - cannotShowCondition: "This condition can't be shown as a value.", - templateShownOn: (tab) => `A template is shown on the ${tab} tab, not here.`, - replaceWithAValue: 'Replace it with a value', - // the sentence starts with the first type - conversion: (from, to) => - `${from.charAt(0).toUpperCase()}${from.slice(1)} is converted to ${to} when the value is used; a value that does not convert fails`, - undo: 'Undo', - undoTooltip: (text) => `Keep "${text}" as a value instead`, - expressionDraftNotUsed: - 'The Expression tab holds text that does not parse, so the field keeps this value until it does.', -}; +export const DEFAULT_VALUE_TABS_LABELS: IValueTabsLabels = DEFAULT_REQRAFT_LABELS.valueTabs; /** A value the host keeps for reuse (qorus#646): saved from one field, offered in every field of its type. */ export interface ITemplateFieldSavedValue { @@ -383,13 +325,6 @@ export interface ITemplateFieldSavedValues { labels?: Partial<{ save: string; saveTooltip: string; use: string; remove: string }>; } -const DEFAULT_SAVED_VALUES_LABELS = { - save: 'Save this value', - saveTooltip: 'Save this value to use it again', - use: 'Use a saved value', - remove: 'Remove saved value', -}; - /** SEAM (reqraft): the badge a template shows in the template picker - where it comes from, as the host knows. */ export type TTemplateBadge = ( template: TReqoreDropdownItem @@ -561,9 +496,10 @@ export const CustomMenuItems = memo( /** @see `loneSectionStartsExpanded` — the menu's only group opens itself. */ startExpanded?: boolean; }) => { + const fieldWords = useReqraftLabels().templateField; return ( { + const fieldWords = useReqraftLabels().templateField; /* The catalogue browsed: `TemplateBrowser` draws its descriptions at the hand-off to Reqore. */ const browsed = useMemo(() => ({ items }) as IReqoreFormTemplates, [items]); @@ -613,7 +550,7 @@ export const TemplateDropdownSelector = memo( // the row is closed cannot read as `$data:{…}` the moment it is opened. const resolved = describeTemplateReference(templates, value); const template = resolved.item; - const label = resolved.label || rest.label || 'Select Template'; + const label = resolved.label || rest.label || fieldWords.selectTemplate; const leftIconProps = useMemo( (): IReqoreButtonProps['leftIconProps'] => ({ image: template?.metadata?.image, @@ -629,8 +566,7 @@ export const TemplateDropdownSelector = memo( {hasOnlyAllowedValues && ( - This field has pre-defined allowed values, make sure the template you select is - compatible with those + {fieldWords.allowedValuesWarning} )} @@ -655,7 +591,7 @@ export const TemplateDropdownSelector = memo( customTheme={TemplatesListProps.listCustomTheme} fixed icon='CloseLine' - tooltip='Remove template value' + tooltip={fieldWords.removeTemplateValue} minimal className='template-remove' compact @@ -738,6 +674,8 @@ const TemplateFieldImpl = memo( label, ...rest }: ITemplateFieldProps & { rowMenu?: IRowMenuRegistration }) => { + const fieldLabels = useReqraftLabels(); + const fieldWords = fieldLabels.templateField; const qorusTypes = useQorusTypes(); const functions = useExpressions({ allow: !!allowFunctions, @@ -1415,7 +1353,7 @@ const TemplateFieldImpl = memo( ); // the values saved for this field's type, and whether this value can be saved - const savedWords = { ...DEFAULT_SAVED_VALUES_LABELS, ...savedValues?.labels }; + const savedWords = { ...fieldLabels.savedValues, ...savedValues?.labels }; const savedForType = useMemo( () => (savedValues?.items ?? []).filter((item) => item.type === type), [savedValues?.items, type] @@ -1609,17 +1547,17 @@ const TemplateFieldImpl = memo( icon='Functions' intent='info' className='dpql-detected-offer' - tooltip={`"${dpqlOffer.text}" could be an expression. It is kept as text; use it as an expression instead?`} + tooltip={fieldWords.expressionOffer(dpqlOffer.text)} onClick={acceptDpqlOffer} > - Use as expression + {fieldWords.useAsExpression} @@ -1749,9 +1687,10 @@ const TemplateFieldImpl = memo( [ { // in a condition field the label says what it is for; the value's tooltip would only repeat it - label: isConditionOption(rest) ? 'Write a condition' : 'Use Expression', + label: + isConditionOption(rest) ? fieldWords.writeCondition : fieldWords.useExpression, icon: 'Functions' as const, - ...(isConditionOption(rest) ? {} : { tooltip: 'Run a function on this value' }), + ...(isConditionOption(rest) ? {} : { tooltip: fieldWords.runFunction }), onClick: handleSelectFunctionChange, }, ] @@ -1759,17 +1698,17 @@ const TemplateFieldImpl = memo( ...(canOfferTemplate ? [ { - label: 'Use Template', + label: fieldWords.useTemplate, icon: 'MoneyDollarCircleLine' as const, /* With no template on offer here - a context whose catalogue has none, or none of this type - the way in leads to an empty list: it is shown, not taken, and says why (qorus#646, David: "Are there cases where no templates or fields are available?"). */ ...(hasTemplatesOnOffer ? - { tooltip: 'Use a template' } + { tooltip: fieldWords.useTemplateTooltip } : { disabled: true, - description: NO_TEMPLATES_ON_OFFER, - tooltip: NO_TEMPLATES_ON_OFFER, + description: fieldWords.noTemplatesOnOffer, + tooltip: fieldWords.noTemplatesOnOffer, }), onClick: handleTemplateToggleClick, }, @@ -1779,9 +1718,9 @@ const TemplateFieldImpl = memo( ...(canOfferCustomValue && !tabsKind ? [ { - label: 'Use Custom Value', + label: fieldWords.useCustomValue, icon: 'EditLine' as const, - tooltip: 'Write the value here instead of choosing a template', + tooltip: fieldWords.useCustomValueTooltip, onClick: handleUseCustomValueClick, }, ] @@ -1818,6 +1757,7 @@ const TemplateFieldImpl = memo( value, savedForType, takeSavedValue, + fieldWords, ] ); @@ -1891,10 +1831,10 @@ const TemplateFieldImpl = memo( : { setIsTemplate(false); setTemplateValue(null); @@ -1942,7 +1882,7 @@ const TemplateFieldImpl = memo( beside. There it goes to the top, which is where the shell's own toolbar is. */ alignSelf: effectiveIsFunction ? 'flex-start' : 'center', - label: hasInputAffordance || !hasValueRows ? undefined : 'Set value', + label: hasInputAffordance || !hasValueRows ? undefined : fieldWords.setValue, style: hasInputAffordance ? { @@ -1973,10 +1913,14 @@ const TemplateFieldImpl = memo( : @@ -1988,14 +1932,18 @@ const TemplateFieldImpl = memo( icon='MoneyDollarCircleLine' className='template-toggle' // none on offer: shown, not taken, and saying why (as the menu's item does) - tooltip={hasTemplatesOnOffer ? 'Use a template' : NO_TEMPLATES_ON_OFFER} + tooltip={ + hasTemplatesOnOffer ? + fieldWords.useTemplateTooltip + : fieldWords.noTemplatesOnOffer + } disabled={!hasTemplatesOnOffer} compact size={rest.size} onClick={handleTemplateToggleClick} > {' '} - Use Template{' '} + {fieldWords.useTemplate}{' '} : null} @@ -2004,13 +1952,13 @@ const TemplateFieldImpl = memo( transparent icon='EditLine' className='template-custom-value' - tooltip='Write the value here instead of choosing a template' + tooltip={fieldWords.useCustomValueTooltip} compact size={rest.size} onClick={handleUseCustomValueClick} > {' '} - Use Custom Value{' '} + {fieldWords.useCustomValue}{' '} : null} @@ -2122,6 +2070,7 @@ const TemplateFieldImpl = memo( savedForType, takeSavedValue, savedValues, + fieldWords, ]); // When the type is a list, and it has an element type - that element type is different @@ -2152,7 +2101,7 @@ const TemplateFieldImpl = memo( // ─── Value · Expression · Visual (or Value · Template) ─────────────────────────────────────────── if (tabsKind && !rest.disabled) { - const words: IValueTabsLabels = { ...DEFAULT_VALUE_TABS_LABELS, ...valueTabsLabels }; + const words: IValueTabsLabels = { ...fieldLabels.valueTabs, ...valueTabsLabels }; /** A type named inside a sentence: the host's own words, or the type catalogue's sentence form (none for no type). */ const typeInSentence: (type: string | undefined) => string | undefined = words.typeName ?? qorusTypes.getTypeSentenceForm; @@ -2509,10 +2458,10 @@ const TemplateFieldImpl = memo( // `muted` as TEXT is 1.3:1 on the app surface. size={rest.size} className='dpql-detected-undo' - tooltip={`Keep "${expressionFromText}" as plain text instead`} + tooltip={fieldWords.keepAsPlainTextInstead(expressionFromText)} onClick={undoExpressionFromText} > - Undo + {fieldWords.undo} : null} {renderControls()} @@ -2607,8 +2556,8 @@ const TemplateFieldImpl = memo( fixed compact size={rest.size} - tooltip='Use a template' - aria-label='Use a template' + tooltip={fieldWords.useTemplateTooltip} + aria-label={fieldWords.useTemplateTooltip} templates={filteredTemplates} focusFilter onItemSelect={handleSelectDateTemplate} diff --git a/src/components/form/fields/template/TemplatePicker.tsx b/src/components/form/fields/template/TemplatePicker.tsx index c7b30887..9b780032 100644 --- a/src/components/form/fields/template/TemplatePicker.tsx +++ b/src/components/form/fields/template/TemplatePicker.tsx @@ -1,9 +1,7 @@ // Copyright 2026 Qore Technologies, s.r.o. +import { reqraftLabels, useReqraftLabels } from '../../../../helpers/reqraftLabels'; import { useTemplatePickerLabels } from '../../../../helpers/templateCatalogueContext'; -import { - DEFAULT_TEMPLATE_PICKER_LABELS, - ITemplatePickerLabels, -} from '../../../../helpers/templateListLabels'; +import { ITemplatePickerLabels } from '../../../../helpers/templateListLabels'; import { rowTitle } from '../../../smartEditor/contextTitle'; import { ReqoreButton, @@ -23,7 +21,7 @@ import { ITemplateNode, loadTemplateChildren, searchTemplateTree, - TEMPLATE_NODE_KIND_LABELS, + templateNodeKindLabel, templateNodeById, templateNodeIntent, templateNodeFor, @@ -123,7 +121,7 @@ const keepTheFocus = (event: React.MouseEvent) => event.preventDefault(); * badges it (the app it comes from, never a type: qorus#646, 8909de2f). */ const rowBadges = (node: ITemplateNode, typeLabel: TTypeLabel): TReqoreBadge[] => { - const kind = TEMPLATE_NODE_KIND_LABELS[node.kind]; + const kind = templateNodeKindLabel(node.kind); const type = node.type ? typeLabel(node.type, node.baseType) : undefined; const app = !node.row?.value && typeof node.row?.badge === 'string' ? node.row.badge : undefined; return [ @@ -189,7 +187,7 @@ export const USE_WHOLE_STYLE: React.CSSProperties = { }; /** What "Use" beside a group says on hover. */ -export const useWholeSays = (label: string) => `Use the whole record: all of ${label} as one value`; +export const useWholeSays = (label: string) => reqraftLabels().templateBrowser.useWhole(label); /** What a row says it is: up to three lines, wrapped, never past the row (the reqraft row rule). */ const ROW_WHAT_STYLE: React.CSSProperties = { @@ -227,13 +225,15 @@ export const TemplatePicker = memo( typeLabel: givenTypeLabel, labels, }: ITemplatePickerProps) => { + const browserWords = useReqraftLabels().templateBrowser; const coarse = useCoarsePointer(); const formTypeLabel = useTypeLabel(); const typeLabel = givenTypeLabel ?? formTypeLabel; + const pickerWords = useReqraftLabels().templatePicker; const hostLabels = useTemplatePickerLabels(); const words = useMemo( - () => ({ ...DEFAULT_TEMPLATE_PICKER_LABELS, ...hostLabels, ...labels }), - [hostLabels, labels] + () => ({ ...pickerWords, ...hostLabels, ...labels }), + [pickerWords, hostLabels, labels] ); const renderMarkdown = useMarkdownRenderer(); const rootRef = useRef(null); @@ -563,7 +563,7 @@ export const TemplatePicker = memo( size='small' className='template-picker-crumbs' role='navigation' - aria-label='Where you are in the templates' + aria-label={browserWords.whereYouAre} > {levels.length ? : null} @@ -590,7 +590,7 @@ export const TemplatePicker = memo( setQuery(''); }} > - Templates + {browserWords.templates} {crumbsHidden ? setEverywhere((current) => !current)} > - {everywhere ? `Only in ${level.label}` : 'Search everywhere'} + {everywhere ? browserWords.onlyIn(level.label) : browserWords.searchEverywhere} : null} @@ -662,14 +662,14 @@ export const TemplatePicker = memo( { // a context whose argument has a format of its own says it (`$rest:` [....]) keyFor.catalogue?.arg_format && !keyFor.catalogue.free_form_keys ? - `Write what ${keyName} reads: ${keyFor.catalogue.arg_format}.` - : `${keyName} holds any keys: write the key to use (a path: order.id).` + browserWords.keyReads(keyName, keyFor.catalogue.arg_format) + : browserWords.keyFree(keyName) } - Use + {browserWords.use} @@ -710,9 +710,7 @@ export const TemplatePicker = memo( style={{ padding: 8 }} > - {`Could not load what is in ${shownFailure.node.label}${ - shownFailure.reason ? `: ${shownFailure.reason}` : '.' - }`} + {browserWords.loadFailed(shownFailure.node.label, shownFailure.reason)} - Retry + {browserWords.retry} : null} }); /** What the chip of a reference nothing names says on hover, where the host says nothing of its own. */ -export const DEFAULT_UNLISTED_REFERENCE_HINT = 'Not one of the values offered here'; +export const DEFAULT_UNLISTED_REFERENCE_HINT = + DEFAULT_REQRAFT_LABELS.templateField.unlistedReference; const TemplateReferenceNamesContext = createContext({}); @@ -91,7 +93,7 @@ export const referenceStanding = ( /** The chip props a reference nothing names draws with: a warning, and the host's hint on hover. */ export const unlistedReferenceChipProps = (names: ITemplateReferenceNames) => ({ intent: 'warning' as const, - tooltip: names.unlistedHint || DEFAULT_UNLISTED_REFERENCE_HINT, + tooltip: names.unlistedHint || reqraftLabels().templateField.unlistedReference, className: UNLISTED_REFERENCE_CLASS, }); diff --git a/src/components/smartEditor/CompletionMenu.tsx b/src/components/smartEditor/CompletionMenu.tsx index a43310cc..6287abf0 100644 --- a/src/components/smartEditor/CompletionMenu.tsx +++ b/src/components/smartEditor/CompletionMenu.tsx @@ -1,4 +1,5 @@ // Copyright 2026 Qore Technologies, s.r.o. +import { reqraftLabels, useReqraftLabels } from '../../helpers/reqraftLabels'; import { rowTitle } from './contextTitle'; import { ReqoreMenu, ReqoreMenuDivider, ReqoreMenuItem } from '@qoretechnologies/reqore'; import { ReqorePopover } from '@qoretechnologies/reqore/dist/components/Popover'; @@ -34,6 +35,7 @@ import { function buildKindBadge( item: ICompletionDropdownItem ): Record | Array> | undefined { + const editorWords = reqraftLabels().editor; /* A template row says what it is (State data, Context, Field) and its type, named: not the LSP kind it is sent as, which called every template a Variable (qorus#646, David's review). */ const marks = (item.raw?.data ?? {}) as { @@ -70,7 +72,7 @@ function buildKindBadge( const warningBadge = item.warning ? { - label: 'Warning', + label: editorWords.warning, size: 'small' as const, intent: 'warning' as const, tooltip: item.warning, @@ -145,6 +147,8 @@ const CompletionMenuItem = memo( onToggleDocs, docsId, }: ICompletionMenuItemProps) => { + const browserWords = useReqraftLabels().templateBrowser; + const editorWords = useReqraftLabels().editor; const badge = useMemo(() => buildKindBadge(item), [item]); const marks = (item.raw?.data ?? {}) as { opens?: boolean; @@ -186,7 +190,10 @@ const CompletionMenuItem = memo( { icon: 'InformationLine' as const, className: 'completion-docs-toggle', - 'aria-label': docsShown ? `Hide what ${item.label} is` : `Show what ${item.label} is`, + 'aria-label': + docsShown ? + editorWords.hideWhatItIs(item.label) + : editorWords.showWhatItIs(item.label), 'aria-expanded': docsShown, 'aria-controls': docsId, onMouseDown: keepTheFocus, @@ -194,16 +201,26 @@ const CompletionMenuItem = memo( ...(coarse ? { style: { minWidth: 44, minHeight: 44 } } : {}), } : undefined, - [hasDocs, onToggleDocs, isFocused, coarse, docsShown, docsId, item.label, item.value] + [ + hasDocs, + onToggleDocs, + isFocused, + coarse, + docsShown, + docsId, + item.label, + item.value, + editorWords, + ] ); const useWhole = useMemo( () => marks.takesWhole ? { - label: 'Use', + label: browserWords.use, className: 'completion-use', - 'aria-label': `Use ${item.label}`, + 'aria-label': browserWords.useNamed(item.label), tooltip: coarse ? undefined : useWholeSays(item.label), compact: true, onMouseDown: keepTheFocus, @@ -211,7 +228,7 @@ const CompletionMenuItem = memo( style: { ...USE_WHOLE_STYLE, ...(coarse ? { minWidth: 44, minHeight: 44 } : {}) }, } : undefined, - [marks.takesWhole, item.label, takeWhole, coarse] + [marks.takesWhole, item.label, takeWhole, coarse, browserWords] ); return ( { + const editorWords = useReqraftLabels().editor; const doc = docOf(item); return doc ?
@@ -497,9 +515,9 @@ export const CompletionMenu = memo( autocomplete.emptyMessage && !autocomplete.isFetching ? autocomplete.emptyMessage : autocomplete.isReplaceMode || autocomplete.isFetching ? (pendingLabel ?? - (autocomplete.isFetching ? 'Loading completions…' : ( - 'No alternatives available' - ))) + (autocomplete.isFetching ? + reqraftLabels().editor.loadingCompletions + : reqraftLabels().editor.noAlternatives)) : undefined } /> diff --git a/src/components/smartEditor/SmartEditor.tsx b/src/components/smartEditor/SmartEditor.tsx index eb541e66..a837bf8b 100644 --- a/src/components/smartEditor/SmartEditor.tsx +++ b/src/components/smartEditor/SmartEditor.tsx @@ -1,5 +1,6 @@ // Copyright 2026 Qore Technologies, s.r.o. +import { useReqraftLabels } from '../../helpers/reqraftLabels'; import { ReqoreButton, ReqoreCallout, @@ -285,14 +286,15 @@ const TemplateButton = ({ notInsertable?: string; open: (rect: { left: number; bottom: number }, anchor: Element) => void; }) => { + const editorWords = useReqraftLabels().editor; return ( event.preventDefault()} onClick={(event: React.MouseEvent) => { @@ -344,6 +346,7 @@ export const SmartEditor = forwardRef { + const editorWords = useReqraftLabels().editor; const theme = useReqoreTheme(); const editorRef = useRef(null); const completionMenuId = useMemo(() => `completion-menu-${++completionMenuCounter}`, []); @@ -980,10 +983,10 @@ export const SmartEditor = forwardRef @@ -1196,8 +1200,8 @@ export const SmartEditor = forwardRef event.preventDefault()} onClick={(event: React.MouseEvent) => { @@ -1213,7 +1217,7 @@ export const SmartEditor = forwardRef - {detailsSaysItsWord ? 'Details' : undefined} + {detailsSaysItsWord ? editorWords.details : undefined} )} diff --git a/src/components/smartEditor/contextTitle.tsx b/src/components/smartEditor/contextTitle.tsx index 15ad1fcb..1353df54 100644 --- a/src/components/smartEditor/contextTitle.tsx +++ b/src/components/smartEditor/contextTitle.tsx @@ -1,4 +1,5 @@ // Copyright 2026 Qore Technologies, s.r.o. +import { reqraftLabels } from '../../helpers/reqraftLabels'; import { ReactNode } from 'react'; /** A template context written to go on into it (`$foreach:`): a row's title for one needs a value after it. */ @@ -16,7 +17,7 @@ export const rowTitle = (title: string): ReactNode => titleNeedsAValue(title) ? {title} - + … diff --git a/src/components/smartEditor/templateCompletions.ts b/src/components/smartEditor/templateCompletions.ts index 234325f4..e5e49b9f 100644 --- a/src/components/smartEditor/templateCompletions.ts +++ b/src/components/smartEditor/templateCompletions.ts @@ -1,4 +1,5 @@ // Copyright 2026 Qore Technologies, s.r.o. +import { DEFAULT_REQRAFT_LABELS, reqraftLabels } from '../../helpers/reqraftLabels'; import { IReqoreFormTemplates } from '@qoretechnologies/reqore/dist/components/Textarea'; import { BaseEditor, Editor, Path, Transforms } from 'slate'; import { ReactEditor } from 'slate-react'; @@ -6,14 +7,13 @@ import { builtInTypeLabel, TTypeLabel } from '../../helpers/qorusTypeCatalogue'; import { templateBaseType, templateType } from '../../helpers/templateType'; import { ITemplateNode, - RECORD_FIELDS_LABEL, searchTemplateReferences, allTemplateNodes, templateContextOf, templateContextsOf, WHOLE_TEMPLATE_CONTEXTS, wholeContextValue, - TEMPLATE_NODE_KIND_LABELS, + templateNodeKindLabel, templateNodeIntent, TTemplateIntent, unbracedReference, @@ -277,7 +277,7 @@ export interface ITemplateRowData { } /** The label of the row that opens the picker at the top of the field's templates. */ -export const BROWSE_ALL_TEMPLATES = 'Browse all templates'; +export const BROWSE_ALL_TEMPLATES = DEFAULT_REQRAFT_LABELS.editor.browseAllTemplates; /** * The rows typing a reference offers (`$`, `$data:`, `$data:receive_order.`, `@na`): the nodes of the field's @@ -369,13 +369,15 @@ const onceEach = (rows: ILspCompletionItem[]): ILspCompletionItem[] => { const wholeContextRow = (context: string): ILspCompletionItem => ({ label: wholeContextValue(context), kind: TEMPLATE_KIND, - detail: `all of ${context}`, + detail: reqraftLabels().editor.allOf(context), insertText: wholeContextValue(context), filterText: wholeContextValue(context), - data: { whole: true, section: 'Templates', kindLabel: 'Template context' } as Record< - string, - unknown - >, + data: { + whole: true, + section: reqraftLabels().editor.templatesSection, + kindLabel: reqraftLabels().editor.templateContext, + isTemplateContext: true, + } as Record, }); /** A context of a catalogue with no key to choose, taken whole in the picker: its context's row here, not a value's. */ @@ -398,9 +400,11 @@ const templateRow = (node: ITemplateNode, typeLabel: TTypeLabel): ILspCompletion isContext ? node.reference : group && node.reference && /^(?:\$[^:]*:.|@.)/.test(node.reference) ? `${node.reference}.` : undefined; - const kindLabel = isContext ? 'Template context' : TEMPLATE_NODE_KIND_LABELS[node.kind]; + const kindLabel = + isContext ? reqraftLabels().editor.templateContext : templateNodeKindLabel(node.kind); const type = node.type ? typeLabel(node.type, node.baseType) : undefined; - const where = node.trail.length ? `in ${node.trail.join(' › ')}` : undefined; + const where = + node.trail.length ? reqraftLabels().editor.inTrail(node.trail.join(' › ')) : undefined; const data: ITemplateRowData = { templateNodeId: node.id, opens: group && !continuation, @@ -408,16 +412,21 @@ const templateRow = (node: ITemplateNode, typeLabel: TTypeLabel): ILspCompletion // a context of keys written freely has none listed, and is taken whole all the same (`$dynamic:*`) takesWhole: wholeHere || (group && !!node.takesWhole), wholeValue: wholeHere || (group && node.takesWhole) ? node.value : undefined, - section: node.kind === 'field' ? RECORD_FIELDS_LABEL : 'Templates', + section: + node.kind === 'field' ? + reqraftLabels().templateTree.recordFields + : reqraftLabels().editor.templatesSection, kindLabel, kindIntent: templateNodeIntent(node), typeLabel: type, }; /* Under its name: where it is, what it is and where it comes from - in the row, never a hover over its edge (qorus#646, David's review). */ - const said = [where, node.description, node.source ? `from ${node.source}` : undefined].filter( - Boolean - ); + const said = [ + where, + node.description, + node.source ? reqraftLabels().editor.fromSource(node.source) : undefined, + ].filter(Boolean); return { label: isContext ? (node.reference ?? node.label) : node.label, kind: node.kind === 'field' ? FIELD_KIND : TEMPLATE_KIND, @@ -431,7 +440,7 @@ const templateRow = (node: ITemplateNode, typeLabel: TTypeLabel): ILspCompletion /** The row that opens the picker at the top of the field's templates: every template, browsed as they nest. */ export const browseAllTemplatesItem = (): ILspCompletionItem => ({ - label: BROWSE_ALL_TEMPLATES, + label: reqraftLabels().editor.browseAllTemplates, kind: 19, insertText: '', filterText: '', @@ -449,7 +458,5 @@ export const browseAllTemplatesItem = (): ILspCompletionItem => ({ export const unlistedContextMessage = (before: string): string | undefined => { const context = /(\$[\w-]+:)[\w.]*$/.exec(before)?.[1]; // said with the rows there are (the whole of it, Browse all templates) or in their place: true either way - return context ? - `Write what ${context} refers to after the ':', or pick from the list` - : undefined; + return context ? reqraftLabels().editor.writeInContext(context) : undefined; }; diff --git a/src/components/smartEditor/useEditingAids.tsx b/src/components/smartEditor/useEditingAids.tsx index cf226e7a..e0671230 100644 --- a/src/components/smartEditor/useEditingAids.tsx +++ b/src/components/smartEditor/useEditingAids.tsx @@ -1,4 +1,5 @@ // Copyright 2026 Qore Technologies, s.r.o. +import { useReqraftLabels } from '../../helpers/reqraftLabels'; import { ReqoreButton, ReqoreControlGroup, ReqoreInput, ReqoreTag } from '@qoretechnologies/reqore'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Editor, Element, NodeEntry, Range, Text, Transforms } from 'slate'; @@ -62,6 +63,7 @@ export const useEditingAids = ({ onFormat, onComplete, }: IUseEditingAidsOptions) => { + const editorWords = useReqraftLabels().editor; const editorOf = () => editorRef.current as TEditor | null; /* A key whose pair is closed, stepped over or taken back whole is not done in its keydown: it is marked here and @@ -432,7 +434,7 @@ export const useEditingAids = ({ fluid wrap role='search' - aria-label='Find in the text' + aria-label={editorWords.findInText} onKeyDown={(event: React.KeyboardEvent) => { if (event.key === 'Escape') { event.preventDefault(); @@ -444,8 +446,8 @@ export const useEditingAids = ({ ) => @@ -465,10 +467,10 @@ export const useEditingAids = ({ size='small' minimal label={ - !find.query ? 'No search' + !find.query ? editorWords.noSearch : matches.length ? - `${current + 1} of ${matches.length}` - : 'No results' + editorWords.matchOf(current + 1, matches.length) + : editorWords.noResults } /> setFind((state) => ({ ...state, caseSensitive: !state.caseSensitive, current: 0 })) } @@ -492,8 +494,8 @@ export const useEditingAids = ({ compact minimal icon='ArrowUpSLine' - aria-label='Previous match' - tooltip='Previous match (Shift+Enter)' + aria-label={editorWords.previousMatch} + tooltip={editorWords.previousMatchTooltip} disabled={!matches.length} onClick={() => step(-1)} /> @@ -503,8 +505,8 @@ export const useEditingAids = ({ compact minimal icon='ArrowDownSLine' - aria-label='Next match' - tooltip='Next match (Enter)' + aria-label={editorWords.nextMatch} + tooltip={editorWords.nextMatchTooltip} disabled={!matches.length} onClick={() => step(1)} /> @@ -515,8 +517,8 @@ export const useEditingAids = ({ compact minimal icon={find.replace ? 'ArrowUpSLine' : 'ArrowDownSLine'} - aria-label={find.replace ? 'Hide replace' : 'Replace'} - tooltip={find.replace ? 'Hide replace' : 'Replace'} + aria-label={find.replace ? editorWords.hideReplace : editorWords.replace} + tooltip={find.replace ? editorWords.hideReplace : editorWords.replace} onClick={() => setFind((state) => ({ ...state, replace: !state.replace }))} /> : null} @@ -526,8 +528,8 @@ export const useEditingAids = ({ compact minimal icon='CloseLine' - aria-label='Close the search' - tooltip='Close (Escape)' + aria-label={editorWords.closeSearch} + tooltip={editorWords.closeSearchTooltip} onClick={closeFind} /> {find.replace && !readOnly ? @@ -535,8 +537,8 @@ export const useEditingAids = ({ ) => setFind((state) => ({ ...state, replacement: event.target.value })) @@ -557,7 +559,7 @@ export const useEditingAids = ({ disabled={current < 0} onClick={() => replaceSpans([matches[current]])} > - Replace + {editorWords.replace} replaceSpans(matches)} > - Replace all + {editorWords.replaceAll} : null} diff --git a/src/components/smartEditor/useLspAutocomplete.ts b/src/components/smartEditor/useLspAutocomplete.ts index 20e614bf..4cb11c1f 100644 --- a/src/components/smartEditor/useLspAutocomplete.ts +++ b/src/components/smartEditor/useLspAutocomplete.ts @@ -1,5 +1,6 @@ // Copyright 2026 Qore Technologies, s.r.o. +import { DEFAULT_REQRAFT_LABELS, reqraftLabels } from '../../helpers/reqraftLabels'; import { debounce } from 'lodash'; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { BaseEditor, BasePoint, Editor, Point, Range, Transforms } from 'slate'; @@ -44,42 +45,8 @@ const DEBOUNCE_MS = 150; // slow responses; sub-250ms responses never set the flag. const LOADER_DELAY_MS = 250; -const COMPLETION_KIND_LABELS: Record = { - 2: 'Methods', - 3: 'Functions', - 5: 'Fields', - 6: 'Variables', - 14: 'Keywords', - 15: 'Snippets', -}; - -export const COMPLETION_KIND_CHIPS: Record = { - 1: 'Text', - 2: 'Method', - 3: 'Function', - 4: 'Constructor', - 5: 'Field', - 6: 'Variable', - 7: 'Class', - 8: 'Interface', - 9: 'Module', - 10: 'Property', - 11: 'Unit', - 12: 'Value', - 13: 'Enum', - 14: 'Keyword', - 15: 'Snippet', - 16: 'Color', - 17: 'File', - 18: 'Reference', - 19: 'Folder', - 20: 'EnumMember', - 21: 'Constant', - 22: 'Struct', - 23: 'Event', - 24: 'Operator', - 25: 'TypeParameter', -}; +export const COMPLETION_KIND_CHIPS: Record = + DEFAULT_REQRAFT_LABELS.editor.completionKinds; export interface ICompletionDropdownItem { label: string; @@ -243,6 +210,7 @@ export interface ITemplateBrowse { * Variable, Function... - when there is more than one. */ export const groupCompletionItems = (items: ICompletionDropdownItem[]): ICompletionGroup[] => { + const editorWords = reqraftLabels().editor; if (items.length === 0) return []; // When the server populates `sortText` it has already ranked items; @@ -259,7 +227,9 @@ export const groupCompletionItems = (items: ICompletionDropdownItem[]): IComplet // a template row says its own section: the field's templates, its record fields, or none (Browse all) const section = templateRowMarks(item).section; const groupLabel = - section !== undefined ? section : (kind && COMPLETION_KIND_LABELS[kind]) || 'Other'; + section !== undefined ? section : ( + (kind && editorWords.completionSections[kind]) || editorWords.otherSection + ); if (!groupMap.has(groupLabel)) { groupMap.set(groupLabel, []); } @@ -288,7 +258,7 @@ export const toDropdownItems = (lspItems: ILspCompletionItem[]): ICompletionDrop kindLabel: templateRowMarks(item).kindLabel ?? (item.kind !== undefined && !templateRowMarks(item).browseAll ? - COMPLETION_KIND_CHIPS[item.kind] + reqraftLabels().editor.completionKinds[item.kind] : undefined), warning: item.warning, raw: item, diff --git a/src/helpers/noDescription.ts b/src/helpers/noDescription.ts index bd8da691..ef69ffc0 100644 --- a/src/helpers/noDescription.ts +++ b/src/helpers/noDescription.ts @@ -1,3 +1,5 @@ +import { DEFAULT_REQRAFT_LABELS } from './reqraftLabels'; + // Copyright 2026 Qore Technologies, s.r.o. /** * What a hover or help says where what it is about has no description: why there is none, in plain words, never a @@ -5,5 +7,5 @@ * hover"). A host can say it in its own words (`ITemplateReferenceNames.noDescription`). */ export const NO_VALUE_DESCRIPTION = 'No description was given for this value.'; -export const NO_OPTION_DESCRIPTION = 'No description was given for this option.'; +export const NO_OPTION_DESCRIPTION = DEFAULT_REQRAFT_LABELS.formEngine.noDescription; export const NO_FIELD_DESCRIPTION = 'No description was given for this field.'; diff --git a/src/helpers/reqraftLabels.ts b/src/helpers/reqraftLabels.ts new file mode 100644 index 00000000..3003c4ea --- /dev/null +++ b/src/helpers/reqraftLabels.ts @@ -0,0 +1,699 @@ +// Copyright 2026 Qore Technologies, s.r.o. +import { useSyncExternalStore } from 'react'; +import { + DEFAULT_TEMPLATE_LIST_LABELS, + DEFAULT_TEMPLATE_PICKER_LABELS, + IReqraftTemplateListLabels, + ITemplatePickerLabels, +} from './templateListLabels'; + +/** + * The words of the template tree: what a row says a node is, its groups, and what a template's color means. Read by + * the picker, typing in a field and hovers alike. + */ +export interface ITemplateTreeLabels { + /** What a row says a node is. */ + kinds: Record< + 'state' | 'context' | 'config' | 'variable' | 'field' | 'template' | 'function' | 'keyword', + string + >; + recordFields: string; + recordFieldsDescription: string; + templateContexts: string; + templateContextsDescription: string; + /** The contexts group as a language server offers it. */ + serverContextsDescription: string; + functions: string; + functionsDescription: string; + valuesAndKeywords: string; + valuesAndKeywordsDescription: string; + /** What a template's color means. */ + intentMeanings: { success: string; custom1: string; info: string }; + /** What a diagnostic says of a reference that names nothing here. */ + referenceNamesNothing: (written: string) => string; +} + +/** The words of a template field's own controls and of the picker's search, levels and states. */ +export interface ITemplateFieldLabels { + setCustomValue: string; + selectTemplate: string; + allowedValuesWarning: string; + removeTemplateValue: string; + /** The tooltip of the offer to read text a field holds as an expression. */ + expressionOffer: (text: string) => string; + useAsExpression: string; + keepAsPlainText: string; + keepAsPlainTextInstead: (text: string) => string; + undo: string; + writeCondition: string; + useExpression: string; + runFunction: string; + useTemplate: string; + useTemplateTooltip: string; + noTemplatesOnOffer: string; + useCustomValue: string; + useCustomValueTooltip: string; + createNewExpression: string; + takesExpression: string; + setValue: string; + /** The tooltip of a chip whose reference is not one of the values offered, where the host says nothing else. */ + unlistedReference: string; +} + +/** + * The words of the value tabs (qorus#646). A host that translates its interface passes its own; a label that + * holds a value is a function of it, so the host keeps its language's word order. + */ +export interface IValueTabsLabels { + value: string; + expression: string; + visual: string; + template: string; + expressionTooltip: string; + visualTooltip: string; + templateTooltip: string; + /** Said on the Value tab for an expression that is not a lone value or template. */ + cannotShowExpression: string; + /** + * The Expression tab's tooltip, in a field that is a condition (`isConditionOption`). The tab keeps its name: + * a condition is written as an expression, and the hover says so (qorus#646, David: "what is "Condition"? Is + * it meant to be an expression?"). + */ + conditionTooltip: string; + /** The Visual tab's tooltip, in a condition field. */ + conditionVisualTooltip: string; + /** Said on the Value tab for a condition that is not a lone value or template. */ + cannotShowCondition: string; + /** Said on the Value tab for a template it cannot show; `tab` is the tab's own label. */ + templateShownOn: (tab: string) => string; + replaceWithAValue: string; + /** A template of another type than the field's; `from` and `to` name the types inside the sentence. */ + conversion: (from: string, to: string) => string; + /** + * The name of a type inside `conversion`'s sentence. Unless a host gives its own, it is the type catalogue's + * sentence form ("a whole number"), in the language the server answered in (qorus#646). + */ + typeName?: (type: string) => string; + undo: string; + /** The undo's tooltip; `text` is what was typed. */ + undoTooltip: (text: string) => string; + /** + * Said on the Value tab while the Expression tab holds text that does not parse: the field keeps the value + * shown here, and the text waits there with its error (qorus#646). + */ + expressionDraftNotUsed?: string; +} + +/** The words of the menu of saved values of a template field. */ +export interface ISavedValuesLabels { + save: string; + saveTooltip: string; + use: string; + remove: string; +} + +/** The words of the template picker's search, levels and states; its actions are `ITemplatePickerLabels`. */ +export interface ITemplateBrowserLabels { + whereYouAre: string; + back: string; + templates: string; + searchTemplates: string; + searchIn: (level: string) => string; + onlyIn: (level: string) => string; + searchEverywhere: string; + keyReads: (key: string, format: string) => string; + keyFree: (key: string) => string; + keyIn: (key: string) => string; + use: string; + useNamed: (label: string) => string; + useWhole: (label: string) => string; + loadFailed: (label: string, reason?: string) => string; + retry: string; + loading: string; + noMatch: (query: string) => string; + nothingHere: string; + showMore: (count: number, left: number) => string; + universalValues: string; +} + +/** The words of an editor that completes as it is typed in (DPQL and any other language it edits). */ +export interface IEditorLabels { + noValueAtTheEnd: string; + templateContext: string; + templatesSection: string; + noRecordFields: string; + noTemplates: string; + unfinishedParts: (hole: string) => string; + browseAllTemplates: string; + allOf: (context: string) => string; + inTrail: (trail: string) => string; + fromSource: (source: string) => string; + writeInContext: (context: string) => string; + valueAfterIt: string; + noLspConnection: string; + validationFailed: string; + warning: string; + hideWhatItIs: (label: string) => string; + showWhatItIs: (label: string) => string; + whatItIs: (label: string) => string; + loadingCompletions: string; + noAlternatives: string; + insertTemplate: string; + connecting: string; + loadingSchema: string; + loading: string; + showWordAtCursor: string; + details: string; + /** A language server's completion kinds (LSP `CompletionItemKind`), each a section of the list. */ + completionSections: Record; + otherSection: string; + /** The same kinds, as the chip of one completion. */ + completionKinds: Record; + findInText: string; + find: string; + noSearch: string; + matchOf: (current: number, total: number) => string; + noResults: string; + matchCase: string; + previousMatch: string; + previousMatchTooltip: string; + nextMatch: string; + nextMatchTooltip: string; + hideReplace: string; + replace: string; + closeSearch: string; + closeSearchTooltip: string; + replaceWith: string; + replaceAll: string; +} + +/** The words of a form: its rows, toolbar, boxes, messages and what a value reads as in its row. */ +export interface IFormEngineLabels { + opensInNewTab: (address: string) => string; + valueIsExpression: string; + oneOf: string; + coveredBy: (label: string) => string; + covers: string; + revertChanges: string; + clearValue: string; + clearValueConfirm: (label: string) => string; + draft: string; + cancelEdit: string; + cancelEditTooltip: string; + closeField: string; + done: string; + defaultIs: (value: string, description: string) => string; + takes: (type: string) => string; + noDescription: string; + helpOn: (label: string) => string; + help: string; + helpFor: (name: string) => string; + moreActions: string; + actionNumber: (index: number) => string; + viewFullscreen: string; + editFullscreen: string; + removeField: string; + collapse: (label: string) => string; + focusedView: string; + lineCount: (count: number) => string; + charCount: (count: number) => string; + fieldDisabled: string; + setByServer: string; + dependenciesNotFulfilled: string; + dependencyUnset: (label: string) => string; + dependsOnOthers: string; + unlockedBy: string; + anyOf: string; + edit: (label: string) => string; + view: (label: string) => string; + fieldActions: string; + sortDefault: string; + sortDefaultTooltip: string; + sortAlpha: string; + sortAlphaTooltip: string; + sortAlphaDesc: string; + sortAlphaDescTooltip: string; + sortUnset: string; + sortUnsetTooltip: string; + sortInvalid: string; + sortInvalidTooltip: string; + incomplete: string; + ready: string; + needAttention: (count: number) => string; + filterFields: string; + fields: string; + requiredOnly: string; + requiredOnlyTooltip: string; + showFieldTypes: string; + hideFieldTypes: string; + showFieldTypesTooltip: string; + sortBy: string; + selectAll: string; + selectAllTooltip: string; + defaultFields: string; + defaultFieldsTooltip: string; + revertAll: string; + revertAllTooltip: string; + hideAllInformation: string; + showAllInformation: string; + failedToLoadOptions: string; + dependentOptionsWarning: string; + boxAttention: string; + boxSet: string; + boxOptional: string; + oneOfRequired: string; + nothingSet: string; + noFieldsMatch: string; + showFewer: string; + moreFields: (count: number, optional: boolean) => string; + unsavedChanges: (count: number) => string; + applyChanges: string; + resolveInvalid: string; + save: string; + discard: string; + noOptions: string; + searchOptionsRequired: string; + showingInvalidOnly: string; + invalidFields: (count: number) => string; + fieldsNotSupported: (count: number) => string; + moreOptions: string; + clickToLearnMore: string; + focusedEditing: string; + removeValue: string; + removeOption: string; + removeSelectedOption: string; + revertChangesTitle: string; + hiddenUntilItApplies: (label: string, conditions: string, invalidReason?: string) => string; + mustHaveNoValue: (label: string) => string; + mustBe: (label: string, value: string) => string; + mustNotBe: (label: string, value: string) => string; + /** How a field's alternative dependencies are joined: "a or b". */ + orJoin: (alternatives: string[]) => string; + unavailable: (dependsOn?: string) => string; + disabledByDependencies: (dependsOn?: string) => string; + valueNotValid: string; + required: string; + noStructuredData: string; + itemCount: (count: number) => string; + fieldCount: (count: number) => string; + set: string; + beforeSaving: (name: string, why: string | undefined, more: number) => string; + schema: string; + tableCount: (count: number) => string; + expression: string; + yes: string; + no: string; +} + +/** Every word Reqraft draws that a host can give in its own language, by where it is drawn. */ +export interface IReqraftLabels { + templateList: IReqraftTemplateListLabels; + templatePicker: ITemplatePickerLabels; + templateTree: ITemplateTreeLabels; + templateField: ITemplateFieldLabels; + valueTabs: IValueTabsLabels; + savedValues: ISavedValuesLabels; + templateBrowser: ITemplateBrowserLabels; + editor: IEditorLabels; + formEngine: IFormEngineLabels; +} + +/** What a host gives: any of the words of any part, and one or more entries of a part's maps. */ +export type TReqraftLabelOverrides = { + [Part in keyof IReqraftLabels]?: { + [Word in keyof IReqraftLabels[Part]]?: IReqraftLabels[Part][Word] extends (...args: never[]) => unknown ? + IReqraftLabels[Part][Word] + : IReqraftLabels[Part][Word] extends object ? Partial + : IReqraftLabels[Part][Word]; + }; +}; + +const plural = (count: number, one: string, many = `${one}s`) => + `${count} ${count === 1 ? one : many}`; + +/** The English words: what a host gets for any word it does not give. */ +export const DEFAULT_REQRAFT_LABELS: IReqraftLabels = { + templateList: DEFAULT_TEMPLATE_LIST_LABELS, + templatePicker: DEFAULT_TEMPLATE_PICKER_LABELS, + templateTree: { + kinds: { + state: 'State data', + context: 'Context', + config: 'Config', + variable: 'Variable', + field: 'Field', + template: 'Template', + function: 'Function', + keyword: 'Keyword', + }, + recordFields: 'Record fields', + recordFieldsDescription: 'The fields of the record the expression is about', + templateContexts: 'Template contexts', + templateContextsDescription: 'Each context, to pick what is in it or to take it whole', + serverContextsDescription: 'Write one and pick what is in it', + functions: 'Functions', + functionsDescription: 'What the expression can call', + valuesAndKeywords: 'Values and keywords', + valuesAndKeywordsDescription: 'Literal values and operators', + intentMeanings: { + success: 'From the event that starts the Qog.', + custom1: 'A value Qorus gives.', + info: 'Any other data this value can use.', + }, + referenceNamesNothing: (written) => + `No template here is called \`${written}\`: pick one from the list, or remove it.`, + }, + templateField: { + setCustomValue: 'Set Custom Value', + selectTemplate: 'Select Template', + allowedValuesWarning: + 'This field has pre-defined allowed values, make sure the template you select is compatible with those', + removeTemplateValue: 'Remove template value', + expressionOffer: (text) => + `"${text}" could be an expression. It is kept as text; use it as an expression instead?`, + useAsExpression: 'Use as expression', + keepAsPlainText: 'Keep it as plain text', + keepAsPlainTextInstead: (text) => `Keep "${text}" as plain text instead`, + undo: 'Undo', + writeCondition: 'Write a condition', + useExpression: 'Use Expression', + runFunction: 'Run a function on this value', + useTemplate: 'Use Template', + useTemplateTooltip: 'Use a template', + noTemplatesOnOffer: 'No templates are offered here for this field.', + useCustomValue: 'Use Custom Value', + useCustomValueTooltip: 'Write the value here instead of choosing a template', + createNewExpression: 'Create New Expression', + takesExpression: 'This field takes an expression.', + setValue: 'Set value', + unlistedReference: 'Not one of the values offered here', + }, + valueTabs: { + value: 'Value', + expression: 'Expression', + visual: 'Visual', + template: 'Template', + expressionTooltip: 'Write an expression - it also takes templates', + visualTooltip: 'Build the expression visually', + templateTooltip: 'Choose a template', + cannotShowExpression: "This expression can't be shown as a value.", + conditionTooltip: 'Write the condition as an expression', + conditionVisualTooltip: 'Build the condition visually', + cannotShowCondition: "This condition can't be shown as a value.", + templateShownOn: (tab) => `A template is shown on the ${tab} tab, not here.`, + replaceWithAValue: 'Replace it with a value', + // the sentence starts with the first type + conversion: (from, to) => + `${from.charAt(0).toUpperCase()}${from.slice(1)} is converted to ${to} when the value is used; a value that does not convert fails`, + undo: 'Undo', + undoTooltip: (text) => `Keep "${text}" as a value instead`, + expressionDraftNotUsed: + 'The Expression tab holds text that does not parse, so the field keeps this value until it does.', + }, + savedValues: { + save: 'Save this value', + saveTooltip: 'Save this value to use it again', + use: 'Use a saved value', + remove: 'Remove saved value', + }, + templateBrowser: { + whereYouAre: 'Where you are in the templates', + back: 'Back', + templates: 'Templates', + searchTemplates: 'Search templates', + searchIn: (level) => `Search in ${level}`, + onlyIn: (level) => `Only in ${level}`, + searchEverywhere: 'Search everywhere', + keyReads: (key, format) => `Write what ${key} reads: ${format}.`, + keyFree: (key) => `${key} holds any keys: write the key to use (a path: order.id).`, + keyIn: (key) => `Key in ${key}`, + use: 'Use', + useNamed: (label) => `Use ${label}`, + useWhole: (label) => `Use the whole record: all of ${label} as one value`, + loadFailed: (label, reason) => + `Could not load what is in ${label}${reason ? `: ${reason}` : '.'}`, + retry: 'Retry', + loading: 'Loading…', + noMatch: (query) => `No template matches "${query}"`, + nothingHere: 'Nothing here', + showMore: (count, left) => `Show ${count} more of ${left}`, + universalValues: 'Universal values that can be used in multiple places', + }, + editor: { + noValueAtTheEnd: + 'A template goes where a value can: after an operator, an opening bracket or a comma, or in an empty expression.', + templateContext: 'Template context', + templatesSection: 'Templates', + noRecordFields: 'No record fields here', + noTemplates: 'No templates here', + unfinishedParts: (hole) => `Fill in each ${hole} (the parts not chosen yet).`, + browseAllTemplates: 'Browse all templates', + allOf: (context) => `all of ${context}`, + inTrail: (trail) => `in ${trail}`, + fromSource: (source) => `from ${source}`, + writeInContext: (context) => + `Write what ${context} refers to after the ':', or pick from the list`, + valueAfterIt: 'a value is written after it', + noLspConnection: 'No LSP connection', + validationFailed: 'Validation request failed', + warning: 'Warning', + hideWhatItIs: (label) => `Hide what ${label} is`, + showWhatItIs: (label) => `Show what ${label} is`, + whatItIs: (label) => `What ${label} is`, + loadingCompletions: 'Loading completions…', + noAlternatives: 'No alternatives available', + insertTemplate: 'Insert a template', + connecting: 'Connecting to language server…', + loadingSchema: 'Loading schema…', + loading: 'Loading…', + showWordAtCursor: 'Show what the word at the cursor is', + details: 'Details', + completionSections: { + 2: 'Methods', + 3: 'Functions', + 5: 'Fields', + 6: 'Variables', + 14: 'Keywords', + 15: 'Snippets', + }, + otherSection: 'Other', + completionKinds: { + 1: 'Text', + 2: 'Method', + 3: 'Function', + 4: 'Constructor', + 5: 'Field', + 6: 'Variable', + 7: 'Class', + 8: 'Interface', + 9: 'Module', + 10: 'Property', + 11: 'Unit', + 12: 'Value', + 13: 'Enum', + 14: 'Keyword', + 15: 'Snippet', + 16: 'Color', + 17: 'File', + 18: 'Reference', + 19: 'Folder', + 20: 'EnumMember', + 21: 'Constant', + 22: 'Struct', + 23: 'Event', + 24: 'Operator', + 25: 'TypeParameter', + }, + findInText: 'Find in the text', + find: 'Find', + noSearch: 'No search', + matchOf: (current, total) => `${current} of ${total}`, + noResults: 'No results', + matchCase: 'Match case', + previousMatch: 'Previous match', + previousMatchTooltip: 'Previous match (Shift+Enter)', + nextMatch: 'Next match', + nextMatchTooltip: 'Next match (Enter)', + hideReplace: 'Hide replace', + replace: 'Replace', + closeSearch: 'Close the search', + closeSearchTooltip: 'Close (Escape)', + replaceWith: 'Replace with', + replaceAll: 'Replace all', + }, + formEngine: { + opensInNewTab: (address) => `${address} (opens in a new tab)`, + valueIsExpression: 'This value is an expression', + oneOf: 'One of', + coveredBy: (label) => `Covered by “${label}”`, + covers: 'Covers', + revertChanges: 'Revert changes', + clearValue: 'Clear value', + clearValueConfirm: (label) => `Clear the current value of "${label}"?`, + draft: 'Draft', + cancelEdit: 'Cancel edit', + cancelEditTooltip: 'Cancel edit — discard the changes made since this field was opened', + closeField: 'Close field', + done: 'Done', + defaultIs: (value, description) => `Default: ${value} — ${description}`, + takes: (type) => `Takes ${type}.`, + noDescription: 'No description was given for this option.', + helpOn: (label) => `Help: ${label}`, + help: 'Help', + helpFor: (name) => `Help For "${name}"`, + moreActions: 'More actions', + actionNumber: (index) => `Action ${index}`, + viewFullscreen: 'View fullscreen', + editFullscreen: 'Edit fullscreen', + removeField: 'Remove field', + collapse: (label) => `Collapse ${label}`, + focusedView: 'Focused View', + lineCount: (count) => plural(count, 'line'), + charCount: (count) => plural(count, 'char'), + fieldDisabled: 'This field is disabled', + setByServer: 'Set by Qorus — not editable', + dependenciesNotFulfilled: 'Disabled — dependencies are not fulfilled', + dependencyUnset: (label) => `${label} unset`, + dependsOnOthers: 'Disabled — depends on other fields. Click to locate them.', + unlockedBy: 'Unlocked by:', + anyOf: 'any of:', + edit: (label) => `Edit ${label}`, + view: (label) => `View ${label}`, + fieldActions: 'Field actions', + sortDefault: 'Default order', + sortDefaultTooltip: 'The schema’s declared field order', + sortAlpha: 'Name A→Z', + sortAlphaTooltip: 'Sort fields by name, ascending', + sortAlphaDesc: 'Name Z→A', + sortAlphaDescTooltip: 'Sort fields by name, descending', + sortUnset: 'Unset first', + sortUnsetTooltip: 'Fields without a value first', + sortInvalid: 'Invalid first', + sortInvalidTooltip: 'Fields needing attention first', + incomplete: 'Incomplete', + ready: 'Ready', + needAttention: (count) => `${count} need attention →`, + filterFields: 'Filter fields...', + fields: 'Fields', + requiredOnly: 'Required only', + requiredOnlyTooltip: 'Show only required fields', + showFieldTypes: 'Show field types', + hideFieldTypes: 'Hide field types', + showFieldTypesTooltip: 'Annotate each field with its type', + sortBy: 'Sort by', + selectAll: 'Select all', + selectAllTooltip: 'Add every optional field', + defaultFields: 'Default fields', + defaultFieldsTooltip: 'Reset to the default set of fields', + revertAll: 'Revert all changes', + revertAllTooltip: 'Undo all edits back to the loaded values', + hideAllInformation: 'Hide all field information', + showAllInformation: 'Show all field information', + failedToLoadOptions: 'Failed to load options', + dependentOptionsWarning: + 'Other options depend on this option, changing it may result in configuration changes.', + boxAttention: 'Needs attention', + boxSet: 'Set', + boxOptional: 'Optional', + oneOfRequired: 'One of the below is required', + nothingSet: 'Nothing is set.', + noFieldsMatch: 'No fields match the current filters.', + showFewer: 'Show fewer', + moreFields: (count, optional) => + `${count} more ${optional ? 'optional ' : ''}field${count === 1 ? '' : 's'}`, + unsavedChanges: (count) => plural(count, 'unsaved change'), + applyChanges: 'Apply the staged changes', + resolveInvalid: 'Resolve the invalid fields before saving', + save: 'Save', + discard: 'Discard', + noOptions: 'No options available', + searchOptionsRequired: + 'This provider record requires some search options to be set. You can set them below.', + showingInvalidOnly: 'Showing invalid fields only. Click here again to show all fields.', + invalidFields: (count) => + `${count < 2 ? 'A field is not valid and requires' : `${count} fields are not valid and require`} attention. Click here to only show invalid fields.`, + fieldsNotSupported: (count) => + `${plural(count, 'field')} hidden because they are not supported on the current instance`, + moreOptions: 'More Options Available', + clickToLearnMore: '. Click to learn more.', + focusedEditing: 'Focused editing', + removeValue: 'Remove value', + removeOption: 'Remove option', + removeSelectedOption: 'Remove Selected Option', + revertChangesTitle: 'Revert Changes', + hiddenUntilItApplies: (label, conditions, invalidReason) => + `"${label}" is hidden until it applies (${conditions}). Its value is kept${ + invalidReason ? `, but it is not valid: ${invalidReason}` : ', and not used until then' + }.`, + mustHaveNoValue: (label) => `${label} must have no value`, + mustBe: (label, value) => `${label} must be "${value}"`, + mustNotBe: (label, value) => `${label} must not be "${value}"`, + orJoin: (alternatives) => `(${alternatives.join(' or ')})`, + unavailable: (dependsOn) => + `Unavailable because some dependencies are not fulfilled${dependsOn ? `: ${dependsOn}` : ''}`, + disabledByDependencies: (dependsOn) => + `This field is disabled because some dependencies are not fulfilled${dependsOn ? `: ${dependsOn}` : ''}`, + valueNotValid: 'Field value is not valid', + required: 'This field is required', + noStructuredData: 'No structured data.', + itemCount: (count) => plural(count, 'item'), + fieldCount: (count) => plural(count, 'field'), + set: 'Set', + beforeSaving: (name, why, more) => + `Before saving, fix ${name}${why ? `: ${why}` : ''}${ + more ? ` (and ${more} more field${more === 1 ? '' : 's'})` : '' + }`, + schema: 'Schema', + tableCount: (count) => plural(count, 'table'), + expression: 'Expression', + yes: 'Yes', + no: 'No', + }, +}; + +const merged = (given: TReqraftLabelOverrides): IReqraftLabels => { + const labels = {} as Record>; + for (const [part, defaults] of Object.entries(DEFAULT_REQRAFT_LABELS)) { + const words = (given as Record | undefined>)[part] ?? {}; + const out: Record = { ...defaults }; + for (const [word, value] of Object.entries(words)) { + const fallback = (defaults as Record)[word]; + if (value === undefined) { + continue; + } + // a map's entries are given one by one: what a row says a node is, a completion's kind + out[word] = + fallback && typeof fallback === 'object' ? { ...fallback, ...(value as object) } : value; + } + labels[part] = out; + } + return labels as unknown as IReqraftLabels; +}; + +let current: IReqraftLabels = DEFAULT_REQRAFT_LABELS; +const listeners = new Set<() => void>(); + +/** + * Gives Reqraft's words in the host's language, merged over the English defaults: a host that gives nothing has today's + * English, and each call replaces what the last one gave. A host calls it when its language changes; what is drawn + * changes with it. It is one setting for the page, not a prop, because the words are read where no component is: the + * trees and completions built outside React, and lists a page keeps between forms. + */ +export const setReqraftLabels = (labels: TReqraftLabelOverrides = {}): void => { + current = merged(labels); + listeners.forEach((listener) => listener()); +}; + +/** The words now: for code outside a component. A component reads `useReqraftLabels`, to be drawn again with new ones. */ +export const reqraftLabels = (): IReqraftLabels => current; + +const subscribe = (listener: () => void) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +}; + +/** The words now, and a new render when a host gives others. */ +export const useReqraftLabels = (): IReqraftLabels => + useSyncExternalStore(subscribe, reqraftLabels, reqraftLabels); diff --git a/src/helpers/templateCatalogue.ts b/src/helpers/templateCatalogue.ts index de23c3a8..0d0b42d2 100644 --- a/src/helpers/templateCatalogue.ts +++ b/src/helpers/templateCatalogue.ts @@ -11,7 +11,8 @@ * (`withTemplateCatalogue`): every container has its Use, every key its row. */ import type { ITemplateNode } from './templateTree'; -import { TEMPLATE_CONTEXTS_LABEL, templateNodeKindOf, unbracedReference } from './templateTree'; +import { reqraftLabels } from './reqraftLabels'; +import { templateNodeKindOf, unbracedReference } from './templateTree'; /** What a context is: a container of keys, a set of values, or an argument written in a format of its own. */ export type TTemplateCatalogueContextKind = 'container' | 'values' | 'formatted' | 'expression'; @@ -151,7 +152,7 @@ export const catalogueContextNode = ( context: ITemplateCatalogueContext, wholeRecords?: boolean, parentId = TEMPLATE_CONTEXTS_GROUP_ID, - trail: string[] = [TEMPLATE_CONTEXTS_LABEL] + trail: string[] = [reqraftLabels().templateTree.templateContexts] ): ITemplateNode => { const reference = catalogueContextReference(context); const id = `${parentId}.${reference}`; @@ -205,9 +206,9 @@ export const withTemplateCatalogue = ( } const group: ITemplateNode = { id: TEMPLATE_CONTEXTS_GROUP_ID, - label: TEMPLATE_CONTEXTS_LABEL, + label: reqraftLabels().templateTree.templateContexts, kind: 'context', - description: 'Each context, to pick what is in it or to take it whole', + description: reqraftLabels().templateTree.templateContextsDescription, trail: [], trailIds: [], // in the order Qorus means them to be read (`sort_priority`), as it lists them where it gives none diff --git a/src/helpers/templateTree.ts b/src/helpers/templateTree.ts index 37e5d85c..a0354be7 100644 --- a/src/helpers/templateTree.ts +++ b/src/helpers/templateTree.ts @@ -10,6 +10,7 @@ * one reference (`$data:receive_order.…`) stands for that reference (`reference`), which is what typing reaches it * by. A node with members may be a value as well (a record taken whole): it then has a `value` too. */ +import { DEFAULT_REQRAFT_LABELS, reqraftLabels } from './reqraftLabels'; import type { IReqoreFormTemplates } from '@qoretechnologies/reqore/dist/components/Textarea'; import { templateBaseType, templateType } from './templateType'; import type { ITemplateCatalogueContext } from './templateCatalogue'; @@ -30,16 +31,13 @@ export type TTemplateNodeKind = /** What a row says a node is, in words. A group says nothing: its members do. */ export const TEMPLATE_NODE_KIND_LABELS: Record = { group: undefined, - state: 'State data', - context: 'Context', - config: 'Config', - variable: 'Variable', - field: 'Field', - template: 'Template', - function: 'Function', - keyword: 'Keyword', + ...DEFAULT_REQRAFT_LABELS.templateTree.kinds, }; +/** What a row says a node is, in the host's words (`setReqraftLabels`). */ +export const templateNodeKindLabel = (kind: TTemplateNodeKind): string | undefined => + kind === 'group' ? undefined : reqraftLabels().templateTree.kinds[kind]; + /** A catalogue row, as far as the tree reads it: a reqore dropdown item with a host's extras. */ export interface ITemplateCatalogueRow { label?: unknown; @@ -276,7 +274,7 @@ export interface ITemplateTreeField { } /** The name of the group a DPQL field's record fields are listed under. */ -export const RECORD_FIELDS_LABEL = 'Record fields'; +export const RECORD_FIELDS_LABEL = DEFAULT_REQRAFT_LABELS.templateTree.recordFields; export interface ITemplateTreeOptions { /** The record's fields, written as `@`: listed as a group of their own, beside the templates. */ @@ -396,10 +394,10 @@ export const templateTree = ( const trail = [RECORD_FIELDS_LABEL]; merged.push({ id, - label: RECORD_FIELDS_LABEL, + label: reqraftLabels().templateTree.recordFields, reference: undefined, kind: 'field', - description: 'The fields of the record the expression is about', + description: reqraftLabels().templateTree.recordFieldsDescription, trail: [], trailIds: [], children: fieldNodes(unlisted, id, trail, '@'), @@ -700,11 +698,8 @@ export type TTemplateIntent = 'success' | 'custom1' | 'info'; * of the colors?"): green is the data of the event that starts the Qog, purple a value Qorus gives, blue any other * template (which its hover does not need to say). */ -export const TEMPLATE_INTENT_MEANINGS: Record = { - success: 'From the event that starts the Qog.', - custom1: 'A value Qorus gives.', - info: 'Any other data this value can use.', -}; +export const TEMPLATE_INTENT_MEANINGS: Record = + DEFAULT_REQRAFT_LABELS.templateTree.intentMeanings; /** The color a catalogue row's template is drawn in: its own metadata's, else its first member's. */ export const templateRowIntent = ( @@ -790,7 +785,7 @@ export const referenceNamesNothing = (roots: ITemplateNode[], written: string): /** What a diagnostic says of a reference that names nothing here. */ export const referenceNamesNothingMessage = (written: string): string => - `No template here is called \`${written}\`: pick one from the list, or remove it.`; + reqraftLabels().templateTree.referenceNamesNothing(written); /** The context a reference is in, with its `$` and `:` (`$data:` of `$data:receive_order.event_type`). */ export const templateContextOf = (reference: string | undefined): string | undefined => @@ -885,31 +880,34 @@ interface IServerCompletion { documentation?: string | { value?: string }; } -const SERVER_GROUPS: Array<{ +const serverGroups = (): Array<{ id: string; label: string; kind: TTemplateNodeKind; description: string; -}> = [ - { - id: 'contexts', - label: 'Template contexts', - kind: 'context', - description: 'Write one and pick what is in it', - }, - { - id: 'functions', - label: 'Functions', - kind: 'function', - description: 'What the expression can call', - }, - { - id: 'keywords', - label: 'Values and keywords', - kind: 'keyword', - description: 'Literal values and operators', - }, -]; +}> => { + const words = reqraftLabels().templateTree; + return [ + { + id: 'contexts', + label: words.templateContexts, + kind: 'context', + description: words.serverContextsDescription, + }, + { + id: 'functions', + label: words.functions, + kind: 'function', + description: words.functionsDescription, + }, + { + id: 'keywords', + label: words.valuesAndKeywords, + kind: 'keyword', + description: words.valuesAndKeywordsDescription, + }, + ]; +}; /** * The template picker's roots: the field's own (`templateTree`) and what the language server completes where a @@ -939,7 +937,7 @@ const rehomed = ( }); /** The name of the picker's group of template contexts. */ -export const TEMPLATE_CONTEXTS_LABEL = 'Template contexts'; +export const TEMPLATE_CONTEXTS_LABEL = DEFAULT_REQRAFT_LABELS.templateTree.templateContexts; /** * The picker's roots with the template contexts of the field's own templates as a group of their own (qorus#646, @@ -966,9 +964,9 @@ export const withTemplateContexts = ( ...roots, { id, - label: TEMPLATE_CONTEXTS_LABEL, + label: reqraftLabels().templateTree.templateContexts, kind: 'context', - description: 'Each context, to pick what is in it or to take it whole', + description: reqraftLabels().templateTree.templateContextsDescription, trail: [], trailIds: [], children: contexts.map((context) => ({ @@ -1046,9 +1044,9 @@ export const withServerCompletions = ( ...out, { id, - label: RECORD_FIELDS_LABEL, + label: reqraftLabels().templateTree.recordFields, kind: 'field', - description: 'The fields of the record the expression is about', + description: reqraftLabels().templateTree.recordFieldsDescription, trail: [], trailIds: [], children: extra, @@ -1112,9 +1110,9 @@ export const withServerCompletions = ( ...out, { id, - label: TEMPLATE_CONTEXTS_LABEL, + label: reqraftLabels().templateTree.templateContexts, kind: 'context', - description: 'Each context, to pick what is in it or to take it whole', + description: reqraftLabels().templateTree.templateContextsDescription, trail: [], trailIds: [], children: members, @@ -1122,7 +1120,7 @@ export const withServerCompletions = ( ]; } const byGroup: Record = { contexts: [], functions, keywords }; - for (const group of SERVER_GROUPS) { + for (const group of serverGroups()) { const members = byGroup[group.id]; if (!members.length) continue; const id = `server.${group.id}`; diff --git a/src/helpers/templates.ts b/src/helpers/templates.ts index ce5dbd91..4756a64a 100644 --- a/src/helpers/templates.ts +++ b/src/helpers/templates.ts @@ -9,7 +9,8 @@ import { import { cloneDeep, size } from 'lodash'; import { createElement } from 'react'; import { ReqraftTemplateExampleValueModal } from '../components/form/fields/template/ExampleValueModal'; -import { DEFAULT_TEMPLATE_LIST_LABELS, IReqraftTemplateListLabels } from './templateListLabels'; +import { reqraftLabels } from './reqraftLabels'; +import { IReqraftTemplateListLabels } from './templateListLabels'; import { describedContent, DESCRIPTION_TOOLTIP_MAX_WIDTH, @@ -25,7 +26,6 @@ import { catalogueGroupFor, catalogueRowTrail, chipPathLabel, - TEMPLATE_INTENT_MEANINGS, templateRowIntent, } from './templateTree'; @@ -566,7 +566,8 @@ export const templateTooltip = ( its row (qorus#646, David: "what is the significance of the colors?") */ const intent = item ? templateRowIntent(item.metadata as Record) : 'info'; // blue is any other data: only green and purple say something of their own - const meaning = intent === 'info' ? undefined : TEMPLATE_INTENT_MEANINGS[intent]; + const meaning = + intent === 'info' ? undefined : reqraftLabels().templateTree.intentMeanings[intent]; const body = [ describedWithType(given ?? (host?.noDescription || NO_VALUE_DESCRIPTION), type), meaning, @@ -783,7 +784,7 @@ export const buildTemplates = ( return undefined; } - const words: IReqraftTemplateListLabels = { ...DEFAULT_TEMPLATE_LIST_LABELS, ...labels }; + const words: IReqraftTemplateListLabels = { ...reqraftLabels().templateList, ...labels }; const mapTemplates = ( items: ITemplateItemPayload[] = [], diff --git a/src/hooks/useTemplates.ts b/src/hooks/useTemplates.ts index 91f93c48..72f2cd23 100644 --- a/src/hooks/useTemplates.ts +++ b/src/hooks/useTemplates.ts @@ -1,3 +1,4 @@ +import { reqraftLabels, useReqraftLabels } from '../helpers/reqraftLabels'; import { IReqoreFormTemplates } from '@qoretechnologies/reqore/dist/components/Textarea'; import { useMemo } from 'react'; import { useAsyncRetry } from 'react-use'; @@ -13,6 +14,23 @@ export interface IUseTemplates { export const TemplatesCache = new Map(); +/* What each list was built from, and in which words: a list outlives the forms that asked for it, so one built before + the host's language changed is built again from what the server said, rather than asked for again. */ +const payloadsBuilt = new Map< + string, + { payload: ITemplatesPayload; words: ReturnType['templateList'] } +>(); + +const cachedTemplates = (interfaceContext: string): IReqoreFormTemplates | undefined => { + const built = payloadsBuilt.get(interfaceContext); + const words = reqraftLabels().templateList; + if (built && built.words !== words && TemplatesCache.has(interfaceContext)) { + TemplatesCache.set(interfaceContext, buildTemplates(built.payload) ?? {}); + payloadsBuilt.set(interfaceContext, { payload: built.payload, words }); + } + return TemplatesCache.get(interfaceContext); +}; + /** * Hand this hook a `getContextData` payload the host has already fetched. * @@ -43,6 +61,7 @@ export const seedTemplatesCache = ( if (templates) { TemplatesCache.set(interfaceContext, templates); + payloadsBuilt.set(interfaceContext, { payload, words: reqraftLabels().templateList }); } }; @@ -58,6 +77,7 @@ export const useTemplates = ( localTemplates: IReqoreFormTemplates = {}, interfaceContext?: string ): IUseTemplates => { + const listWords = useReqraftLabels().templateList; const shouldFetch = !!allow && !!interfaceContext; const globalTemplates = useAsyncRetry(async () => { @@ -65,7 +85,7 @@ export const useTemplates = ( /* Already answered — by a previous mount or by the host seeding it. The cache was consulted for `loading` and for the value but not here, so a seeded page still issued the request it had been handed the answer to. */ - const cached = TemplatesCache.get(interfaceContext!); + const cached = cachedTemplates(interfaceContext!); if (cached) { return cached; } @@ -81,6 +101,10 @@ export const useTemplates = ( const templates = buildTemplates(serverTemplates.data) ?? {}; TemplatesCache.set(interfaceContext!, templates); + payloadsBuilt.set(interfaceContext!, { + payload: serverTemplates.data, + words: reqraftLabels().templateList, + }); return templates; } @@ -105,14 +129,13 @@ export const useTemplates = ( return undefined; } - const globalTemplatesValue = - TemplatesCache.get(interfaceContext!) || globalTemplates.value || {}; + const globalTemplatesValue = cachedTemplates(interfaceContext!) || globalTemplates.value || {}; return { ...globalTemplatesValue, items: [...(globalTemplatesValue?.items || []), ...(localTemplates?.items || [])], }; - }, [allow, shouldFetch, interfaceContext, globalTemplates, localTemplates]); + }, [allow, shouldFetch, interfaceContext, globalTemplates, localTemplates, listWords]); return { loading: shouldFetch && globalTemplates.loading && !TemplatesCache.has(interfaceContext!), diff --git a/src/index.tsx b/src/index.tsx index b5414543..99f0c789 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -5,6 +5,7 @@ export * from './components/log/Log'; export * from './helpers/templateCatalogue'; export * from './helpers/templateCatalogueContext'; // the words a list of templates is read with, which a host that translates its interface passes to `buildTemplates` +export * from './helpers/reqraftLabels'; export * from './helpers/templateListLabels'; export { validateField, From 28b533f18b6adc00bc8329b563709566dee47190 Mon Sep 17 00:00:00 2001 From: David Nichols Date: Thu, 5 Nov 2026 09:50:31 +0100 Subject: [PATCH 2/7] chore(release): version 0.10.73, one bump from the base as the checks require --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e5351a94..66c0097a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@qoretechnologies/reqraft", - "version": "0.10.74", + "version": "0.10.73", "description": "ReQraft is a collection of React components and hooks that are used across Qore Technologies' products made using the ReQore component library from Qore.", "main": "dist/index.js", "types": "dist/index.d.ts", From 5d21729c0bb5836c3fd8eb6d998bf159a623788b Mon Sep 17 00:00:00 2001 From: David Nichols Date: Thu, 5 Nov 2026 18:39:09 +0100 Subject: [PATCH 3/7] fix(expressions): the types a place takes say each name once An expected type listed the same words twice, "a value of type Integer or a value of type Integer": a place takes a list of types, and several of them are one name, because a soft or optional type is its base type and two types can share a sentence form (string and richtext are both "text"). The builder's sentence now says each once, through the one join argumentLabel also uses, and the word that joins them is a host's (formEngine.typesJoin). --- __tests__/typesInASentence.test.tsx | 53 +++++++++++++++++++ .../expressions/builder/argumentLabel.tsx | 7 ++- .../form/expressions/builder/index.tsx | 13 +++-- src/helpers/qorusTypeCatalogue.ts | 14 +++++ src/helpers/reqraftLabels.ts | 3 ++ 5 files changed, 79 insertions(+), 11 deletions(-) create mode 100644 __tests__/typesInASentence.test.tsx diff --git a/__tests__/typesInASentence.test.tsx b/__tests__/typesInASentence.test.tsx new file mode 100644 index 00000000..95103e74 --- /dev/null +++ b/__tests__/typesInASentence.test.tsx @@ -0,0 +1,53 @@ +// Copyright 2026 Qore Technologies, s.r.o. +/* What a place takes, said once for each name: types can be one name, and a host says how they are joined. */ +import { afterEach, describe, expect, it } from 'vitest'; +import { + defaultQorusTypes, + qorusTypeSentenceForm, + typesInASentence, +} from '../src/helpers/qorusTypeCatalogue'; +import { setReqraftLabels } from '../src/helpers/reqraftLabels'; + +const form = + (types = defaultQorusTypes) => + (name: string | undefined) => + qorusTypeSentenceForm(types, name); + +afterEach(() => { + setReqraftLabels(); +}); + +describe('the types a place takes, in a sentence', () => { + it('says once a name two types share', () => { + // no sentence form on an older server: the display name, twice + const old = [ + { name: 'int', display_name: 'Integer' }, + { name: 'integer', display_name: 'Integer' }, + ] as never; + expect(typesInASentence(['int', 'integer'], form(old))).toBe('a value of type Integer'); + }); + + it('says once what a soft or optional type is the same as', () => { + expect(typesInASentence(['int', 'softint', '*int'], form())).toBe('a whole number'); + }); + + it('says once two types of one sentence form', () => { + expect(typesInASentence(['string', 'richtext'], form())).toBe('text'); + }); + + it('joins what differs, in order', () => { + expect(typesInASentence(['int', 'string'], form())).toBe('a whole number or text'); + }); + + it('has nothing to say of no type', () => { + expect(typesInASentence([], form())).toBeUndefined(); + expect(typesInASentence([undefined], form())).toBeUndefined(); + }); + + it('joins in a host’s words, and again when the language changes', () => { + setReqraftLabels({ formEngine: { typesJoin: (types) => types.join(' oder ') } }); + expect(typesInASentence(['int', 'string'], form())).toBe('a whole number oder text'); + setReqraftLabels(); + expect(typesInASentence(['int', 'string'], form())).toBe('a whole number or text'); + }); +}); diff --git a/src/components/form/expressions/builder/argumentLabel.tsx b/src/components/form/expressions/builder/argumentLabel.tsx index 2427739e..ab557ed1 100644 --- a/src/components/form/expressions/builder/argumentLabel.tsx +++ b/src/components/form/expressions/builder/argumentLabel.tsx @@ -1,5 +1,6 @@ +import { typesInASentence } from '../../../../helpers/qorusTypeCatalogue'; import { ReqoreIcon, ReqoreP, ReqoreSpan } from '@qoretechnologies/reqore'; -import { compact, uniq } from 'lodash'; +import { uniq } from 'lodash'; import { argumentMatchesType, getArgumentType } from '../../../../helpers/expressions'; import { validateFieldWithResult } from '../../../../helpers/validations'; import { useQorusTypes } from '../../../../hooks/useQorusTypes'; @@ -62,9 +63,7 @@ export const ExpressionBuilderArgumentLabel = ({ ...types.getAcceptedTypes(schema.ui_type), ]); /** The types this argument takes, named inside a sentence: "a whole number or a number" (qorus#646). */ - const acceptedTypeNames = uniq(compact(acceptedTypes.map((t) => types.getTypeSentenceForm(t)))).join( - ' or ' - ); + const acceptedTypeNames = typesInASentence(acceptedTypes, (t) => types.getTypeSentenceForm(t)) ?? ''; /* `arg` is optional — an operand the author has not filled in yet has no entry at all, and a literal carried straight from a parse can be the null literal itself. Both were dereferenced unguarded here, which took the whole diff --git a/src/components/form/expressions/builder/index.tsx b/src/components/form/expressions/builder/index.tsx index 947fa972..c431ec32 100644 --- a/src/components/form/expressions/builder/index.tsx +++ b/src/components/form/expressions/builder/index.tsx @@ -1,3 +1,4 @@ +import { typesInASentence } from '../../../../helpers/qorusTypeCatalogue'; import { ReqoreControlGroup, ReqoreErrorBoundary, @@ -19,7 +20,7 @@ import { IReqoreMenuItemProps } from '@qoretechnologies/reqore/dist/components/M import { IReqoreIconName } from '@qoretechnologies/reqore/dist/types/icons'; import { IReqoreFormTemplates } from '@qoretechnologies/reqore/dist/components/Textarea'; import { TQorusType } from '@qoretechnologies/ts-toolkit'; -import { clone, cloneDeep, compact, get, isArray, set, size, unset } from 'lodash'; +import { clone, cloneDeep, get, isArray, set, size, unset } from 'lodash'; import { darken, rgba } from 'polished'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import styled from 'styled-components'; @@ -266,12 +267,10 @@ export const Expression = ({ const typeNotKnownYet = !!value.value?.exp && !serverType; // the type(s) this place takes, named inside a sentence: their sentence forms; none where no type is known // (qorus#646) - const expectedReturnTypeText = - compact( - (isArray(returnType) ? returnType : [returnType]).map((type) => - types.getTypeSentenceForm(type, returnBaseType) - ) - ).join(' or ') || undefined; + const expectedReturnTypeText = typesInASentence( + isArray(returnType) ? returnType : [returnType], + (type) => types.getTypeSentenceForm(type, returnBaseType) + ); // Server Handler expressions is either defined by the option // then it's always the first level diff --git a/src/helpers/qorusTypeCatalogue.ts b/src/helpers/qorusTypeCatalogue.ts index 9595ad00..f853e2d2 100644 --- a/src/helpers/qorusTypeCatalogue.ts +++ b/src/helpers/qorusTypeCatalogue.ts @@ -2,6 +2,7 @@ // The Qorus server's type catalogue as reqraft keeps it (qorus#646): what each type is called as a label (its // display name) and inside a sentence (its sentence form). No React here: the form's validation names types too, // and the hook (`useQorusTypes`) that answers with the server's catalogue builds on it. +import { reqraftLabels } from './reqraftLabels'; import { IReqoreIconName } from '@qoretechnologies/reqore/dist/types/icons'; import { TQorusType } from '@qoretechnologies/ts-toolkit'; @@ -483,6 +484,19 @@ export const qorusTypeSentenceForm = ( return typeName.includes('/') ? `a value of type ${typeName}` : 'a value of an unknown type'; }; +/** + * The types a place takes, named inside a sentence: each name said once. Several types are one name: a soft or optional + * type is its base type (`softint`, `*int`: "a whole number"), and two types can share a sentence form (`string` and + * `richtext`: "text"). Undefined where none has one. + */ +export const typesInASentence = ( + typeNames: (string | undefined)[], + sentenceForm: (typeName: string | undefined) => string | undefined +): string | undefined => { + const forms = [...new Set(typeNames.map(sentenceForm).filter((form): form is string => !!form))]; + return forms.length ? reqraftLabels().formEngine.typesJoin(forms) : undefined; +}; + /** What a type is called inside a sentence ("a whole number"), by a catalogue: `useQorusTypes().getTypeSentenceForm`. */ export type TTypeSentenceForm = ( typeName: string | undefined, diff --git a/src/helpers/reqraftLabels.ts b/src/helpers/reqraftLabels.ts index 3003c4ea..973db6de 100644 --- a/src/helpers/reqraftLabels.ts +++ b/src/helpers/reqraftLabels.ts @@ -289,6 +289,8 @@ export interface IFormEngineLabels { mustNotBe: (label: string, value: string) => string; /** How a field's alternative dependencies are joined: "a or b". */ orJoin: (alternatives: string[]) => string; + /** The types a place takes, each named inside a sentence: "a whole number or text". */ + typesJoin: (types: string[]) => string; unavailable: (dependsOn?: string) => string; disabledByDependencies: (dependsOn?: string) => string; valueNotValid: string; @@ -629,6 +631,7 @@ export const DEFAULT_REQRAFT_LABELS: IReqraftLabels = { mustBe: (label, value) => `${label} must be "${value}"`, mustNotBe: (label, value) => `${label} must not be "${value}"`, orJoin: (alternatives) => `(${alternatives.join(' or ')})`, + typesJoin: (types) => types.join(' or '), unavailable: (dependsOn) => `Unavailable because some dependencies are not fulfilled${dependsOn ? `: ${dependsOn}` : ''}`, disabledByDependencies: (dependsOn) => From 8f5cd688659d6aa7183d89b7448bac5a7fc7699b Mon Sep 17 00:00:00 2001 From: David Nichols Date: Thu, 5 Nov 2026 18:45:30 +0100 Subject: [PATCH 4/7] fix(expressions): the types in a sentence build for the package's target --- src/helpers/qorusTypeCatalogue.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/helpers/qorusTypeCatalogue.ts b/src/helpers/qorusTypeCatalogue.ts index f853e2d2..b4bcdbba 100644 --- a/src/helpers/qorusTypeCatalogue.ts +++ b/src/helpers/qorusTypeCatalogue.ts @@ -493,7 +493,9 @@ export const typesInASentence = ( typeNames: (string | undefined)[], sentenceForm: (typeName: string | undefined) => string | undefined ): string | undefined => { - const forms = [...new Set(typeNames.map(sentenceForm).filter((form): form is string => !!form))]; + const forms = Array.from( + new Set(typeNames.map(sentenceForm).filter((form): form is string => !!form)) + ); return forms.length ? reqraftLabels().formEngine.typesJoin(forms) : undefined; }; From de29c8a7992028161a48f6737ef77f2f4b8b3665 Mon Sep 17 00:00:00 2001 From: David Nichols Date: Thu, 5 Nov 2026 20:42:20 +0100 Subject: [PATCH 5/7] feat(form-engine): a host can open rows of a drawn form (expandOptionsRequest) initialExpandedOptions is spent once, and a host that had to open a row later could only draw the form again, which threw away what was open and typed in its other rows. Each new expandOptionsRequest key opens the rows it names alongside those already open; a key already answered opens nothing again. qorus-ide needs it to take the reader to a field a save refused, in a row the reader has closed. --- .../formEngineExpandOptionsRequest.test.tsx | 81 +++++++++++++++++++ design/IDE_INTEGRATION.md | 4 + src/components/form/engine/FormEngine.tsx | 32 ++++++++ 3 files changed, 117 insertions(+) create mode 100644 __tests__/formEngineExpandOptionsRequest.test.tsx diff --git a/__tests__/formEngineExpandOptionsRequest.test.tsx b/__tests__/formEngineExpandOptionsRequest.test.tsx new file mode 100644 index 00000000..4026e3d7 --- /dev/null +++ b/__tests__/formEngineExpandOptionsRequest.test.tsx @@ -0,0 +1,81 @@ +// Copyright 2026 Qore Technologies, s.r.o. +/* A host opens a row of a form that is already drawn, without drawing the form again (`expandOptionsRequest`). */ +import { ReqoreUIProvider } from '@qoretechnologies/reqore'; +import { act, fireEvent, render, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { FormEngine } from '../src/components/form/engine/FormEngine'; +import { FetchContext } from '../src/contexts/FetchContext'; +import { emptyFetchContext } from './support/fetchContext'; + +const OPTIONS = { + first: { type: 'string', display_name: 'First' }, + second: { type: 'string', display_name: 'Second' }, +}; +const VALUE = { first: { type: 'string', value: 'a' }, second: { type: 'string', value: 'b' } }; + +const draw = ( + props: Record = {}, + options: unknown = OPTIONS, + value: unknown = VALUE +) => ( + + + + + +); + +const open = (name: string) => + !!document.querySelector(`[data-field="${name}"]`)?.querySelector('.readfirst-row-editing') || + !!document.querySelector(`[data-field="${name}"].readfirst-row-editing`); + +describe('a row a host asks to open', () => { + it('opens in the form as it is drawn, and what the reader opened stays open', async () => { + const { rerender } = render(draw()); + await waitFor(() => expect(document.querySelector('[data-field="first"]')).toBeTruthy()); + act(() => { + fireEvent.click(document.querySelector('[data-field="first"]')!); + }); + await waitFor(() => expect(open('first')).toBe(true)); + rerender(draw({ expandOptionsRequest: { options: ['second'], key: 1 } })); + await waitFor(() => expect(open('second')).toBe(true)); + // drawn again, the row the reader opened would be closed + expect(open('first')).toBe(true); + }); + + it('is not opened again for a key it has answered, and is for a new one', async () => { + const { rerender } = render(draw({ expandOptionsRequest: { options: ['second'], key: 1 } })); + await waitFor(() => expect(open('second')).toBe(true)); + const done = document + .querySelector('[data-field="second"]') + ?.querySelector('[aria-label="Done"], [aria-label="Close field"]'); + expect(done, 'the row’s close').toBeTruthy(); + act(() => { + fireEvent.click(done!); + }); + await waitFor(() => expect(open('second')).toBe(false)); + rerender(draw({ expandOptionsRequest: { options: ['second'], key: 1 } })); + expect(open('second')).toBe(false); + rerender(draw({ expandOptionsRequest: { options: ['second'], key: 2 } })); + await waitFor(() => expect(open('second')).toBe(true)); + }); + + it('waits for a row the form does not have yet', async () => { + const request = { options: ['second'], key: 'late' }; + const firstOnly = { first: VALUE.first }; + const { rerender } = render( + draw({ expandOptionsRequest: request }, { first: OPTIONS.first }, firstOnly) + ); + await waitFor(() => expect(document.querySelector('[data-field="first"]')).toBeTruthy()); + expect(open('second')).toBe(false); + rerender(draw({ expandOptionsRequest: request })); + await waitFor(() => expect(open('second')).toBe(true)); + }); +}); diff --git a/design/IDE_INTEGRATION.md b/design/IDE_INTEGRATION.md index 1672faf4..c9934ceb 100644 --- a/design/IDE_INTEGRATION.md +++ b/design/IDE_INTEGRATION.md @@ -107,3 +107,7 @@ It is one setting for the page, not a prop, because the words are read where no A word a component or provider is given itself (`buildTemplates`' `labels`, `TemplatePicker`'s `labels`, `TemplateCatalogueProvider`'s `pickerLabels`, `valueTabsLabels`, `compactBoxLabels`) comes before the host's. Code does not compare a word to decide what to do: a row says what it is with a flag (`isTemplateContext`), and the label is only drawn. +## Opening a row of a drawn form + +`initialExpandedOptions` names the rows a compact form starts with and is spent once. A host that must take the reader to a row later - a save refused for a field inside a row the reader has closed - passes `expandOptionsRequest: { options, key }`: each new key opens the named rows alongside those already open, without drawing the form again, so nothing open or typed in its other rows is lost. A key already answered opens nothing again. + diff --git a/src/components/form/engine/FormEngine.tsx b/src/components/form/engine/FormEngine.tsx index a0fead62..1d02db5c 100644 --- a/src/components/form/engine/FormEngine.tsx +++ b/src/components/form/engine/FormEngine.tsx @@ -1354,6 +1354,16 @@ export interface IFormEngineProps extends Omit [...prev, ...toExpand.filter((name) => !prev.includes(name))]); }, [initialExpandedOptions, compact, availableOptions]); + // --- Host-requested rows (opt-in) ---------------------------------------- + // Each new key opens the rows it names; a key already answered is not answered again. Waits, as the starting + // rows do, for a schema that does not yet hold a named row. + const answeredExpandRequestRef = useRef(undefined); + useEffect(() => { + if (!expandOptionsRequest || !compact) { + return; + } + const { options: requested, key } = expandOptionsRequest; + if (answeredExpandRequestRef.current === key) { + return; + } + const names = Object.keys(availableOptions); + const toExpand = requested.filter((name) => names.includes(name)); + if (!toExpand.length) { + return; + } + answeredExpandRequestRef.current = key; + setExpandedOptions((prev) => [...prev, ...toExpand.filter((name) => !prev.includes(name))]); + }, [expandOptionsRequest, compact, availableOptions]); + // --- First-attention-field autofocus (opt-in) ----------------------------- /** The first row (of `names`, in order: every row by default) that needs an answer and can take one, if any: what * the first-required scan opens. */ From 11f752dd97d7a402c2ada30b0227b828d1d148a5 Mon Sep 17 00:00:00 2001 From: David Nichols Date: Thu, 5 Nov 2026 22:51:14 +0100 Subject: [PATCH 6/7] fix(templates): a picker opened in the text stays with the text when the window is resized A picker opened by a click in the text, at the caret, had nothing to follow when the window's width changed: it stayed at the point of the window it opened at while the text moved away (qorus-ide #301, qlip #370: the picker at the left of the canvas, its field at the right). It now goes with the text it was opened in, and any picker stays as far from what it was opened from as when it opened, so after a resize it is where opening it again would draw it. --- __tests__/autocompleteBeforeReady.test.ts | 49 +++++++++++++++++++ .../smartEditor/useLspAutocomplete.ts | 33 ++++++++++--- 2 files changed, 76 insertions(+), 6 deletions(-) diff --git a/__tests__/autocompleteBeforeReady.test.ts b/__tests__/autocompleteBeforeReady.test.ts index 9a9f5f70..d59e2a64 100644 --- a/__tests__/autocompleteBeforeReady.test.ts +++ b/__tests__/autocompleteBeforeReady.test.ts @@ -9,6 +9,7 @@ import { act, renderHook } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { defaultSlateConverter } from '../src/components/smartEditor/helpers'; import { useLspAutocomplete } from '../src/components/smartEditor/useLspAutocomplete'; +import { ReactEditor } from 'slate-react'; /** The editor as Slate holds it after a key: its text, the caret at the end, and the edit's operations. */ const editorWith = (text: string, operations: { type: string }[] = [{ type: 'insert_text' }]) => @@ -220,4 +221,52 @@ describe('the template picker when the window is resized', () => { button.remove(); } }); + + /* A click in the text opens the picker at the caret, with nothing it was opened from: it was left at the point of the + window it opened at while the window's width moved the text away (qorus-ide #301, qlip #370). */ + it('opened at the caret, stays where it was opened in the text as the text moves', () => { + const { hook } = setup(true); + const text = document.createElement('div'); + document.body.appendChild(text); + const drawn = vi.spyOn(ReactEditor, 'toDOMNode').mockReturnValue(text); + try { + const measure = vi.spyOn(text, 'getBoundingClientRect').mockReturnValue(boxAt(320, 58) as DOMRect); + // the caret 40 px into the text, on its first line + act(() => + hook.result.current.openBrowse(editorWith('hello') as never, { + rect: { left: 360, bottom: 40 }, + caret: { path: [0, 0], offset: 2 }, + } as never) + ); + expect(hook.result.current.position).toEqual({ left: 360, top: 40 }); + + measure.mockReturnValue(boxAt(900, 58) as DOMRect); + resized(); + expect(hook.result.current.position).toEqual({ left: 940, top: 40 }); + expect(hook.result.current.browse).toBeTruthy(); + } finally { + drawn.mockRestore(); + text.remove(); + } + }); + + it('opened from an element, stays as far from it as when it opened', () => { + const { hook } = setup(true); + const chip = document.createElement('span'); + document.body.appendChild(chip); + try { + const measure = vi.spyOn(chip, 'getBoundingClientRect').mockReturnValue(boxAt(100, 30) as DOMRect); + act(() => + hook.result.current.openBrowse(editorWith('') as never, { + rect: { left: 100, bottom: 34 }, + anchor: chip, + } as never) + ); + measure.mockReturnValue(boxAt(600, 130) as DOMRect); + resized(); + expect(hook.result.current.position).toEqual({ left: 600, top: 134 }); + } finally { + chip.remove(); + } + }); }); diff --git a/src/components/smartEditor/useLspAutocomplete.ts b/src/components/smartEditor/useLspAutocomplete.ts index 4cb11c1f..1e4a8d8d 100644 --- a/src/components/smartEditor/useLspAutocomplete.ts +++ b/src/components/smartEditor/useLspAutocomplete.ts @@ -186,7 +186,10 @@ export interface IUseLspAutocompleteResult { editor: BaseEditor & ReactEditor & HistoryEditor, options: { rect: { left: number; bottom: number }; - /** What it is opened from (a button, a chip), where that is not the caret: it stays there as the window resizes. */ + /** + * What it is opened from (a button, a chip): it stays there as the window resizes. Without one - opened at the + * caret or at the text - it stays where it was opened in the text, which moves with it. + */ anchor?: Element; caret?: BasePoint; replacementPath?: number[]; @@ -393,7 +396,8 @@ export function useLspAutocomplete( // the template picker in place of the rows, and where it is open const [browse, setBrowseState] = useState(null); const browseRef = useRef(null); - const browseAnchorRef = useRef(null); + // what the picker goes with as the window resizes, and where it is drawn from it + const browseAnchorRef = useRef<{ element: Element; left: number; bottom: number } | null>(null); const setBrowse = useCallback((next: ITemplateBrowse | null) => { browseRef.current = next; setBrowseState(next); @@ -1226,9 +1230,9 @@ export function useLspAutocomplete( const follow = () => { const anchor = browseAnchorRef.current; // the picker's: the list that typing has put in its place is at the caret - if (browseRef.current && anchor?.isConnected) { - const { left, bottom } = anchor.getBoundingClientRect(); - setPosition({ left, top: bottom }); + if (browseRef.current && anchor?.element.isConnected) { + const { left, bottom } = anchor.element.getBoundingClientRect(); + setPosition({ left: left + anchor.left, top: bottom + anchor.bottom }); } }; window.addEventListener('resize', follow); @@ -1238,7 +1242,24 @@ export function useLspAutocomplete( const openBrowse = useCallback( (editor, { rect, anchor, caret, replacementPath, levelId, query, selectedValue, arrows }) => { editorRef.current = editor; - browseAnchorRef.current = anchor ?? null; + /* It goes with what it was opened from; opened at the caret or the text, with the text it was opened in, as + far from it as when it opened: a picker opened by a click in the text was left at the point of the window it + opened at while the window's width moved the text away (qorus-ide #301, qlip #370). */ + let from: Element | null = anchor ?? null; + if (!from) { + try { + from = ReactEditor.toDOMNode(editor as ReactEditor, editor as ReactEditor); + } catch { + // the editor is not drawn: there is nothing to go with + from = null; + } + } + if (from) { + const at = from.getBoundingClientRect(); + browseAnchorRef.current = { element: from, left: rect.left - at.left, bottom: rect.bottom - at.bottom }; + } else { + browseAnchorRef.current = null; + } requestCompletions.cancel(); requestGenerationRef.current++; pendingRequestRef.current = false; From b78d5caefa40f200edb4af2274baa5720a61da8f Mon Sep 17 00:00:00 2001 From: David Nichols Date: Fri, 6 Nov 2026 11:29:04 +0100 Subject: [PATCH 7/7] chore: version 0.10.74 (0.10.73 was released from #159) --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 66c0097a..e5351a94 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@qoretechnologies/reqraft", - "version": "0.10.73", + "version": "0.10.74", "description": "ReQraft is a collection of React components and hooks that are used across Qore Technologies' products made using the ReQore component library from Qore.", "main": "dist/index.js", "types": "dist/index.d.ts",