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/__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/__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/__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/design/IDE_INTEGRATION.md b/design/IDE_INTEGRATION.md index fd5b5a78..c9934ceb 100644 --- a/design/IDE_INTEGRATION.md +++ b/design/IDE_INTEGRATION.md @@ -98,3 +98,16 @@ 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. + +## 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/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", 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..1d02db5c 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, @@ -1353,6 +1354,16 @@ export interface IFormEngineProps extends Omit { + const feWords = useReqraftLabels().formEngine; // Built-ins + whatever the consumer declared for its own injected editors. const isRendererOnly = useMemo( () => createRendererOnlyUiTypeCheck(rendererOnlyUiTypes), @@ -1931,7 +1944,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 +1952,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 +2373,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 +2381,7 @@ const FormEngineImpl = ({ return badges; }, - [options] + [options, feWords] ); const fixedValue: TQorusForm = (localValue.fields || {}) as TQorusForm; @@ -3016,6 +3028,27 @@ const FormEngineImpl = ({ setExpandedOptions((prev) => [...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. */ @@ -4183,14 +4216,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 +4303,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 +4464,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 +4665,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 +4732,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 +4797,7 @@ const FormEngineImpl = ({ if (!options || !size(options)) { return ( - No options available + {feWords.noOptions} ); } @@ -4784,9 +4817,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 +4847,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 +4868,7 @@ const FormEngineImpl = ({ handleAddOptionalFieldChange('options', val)} - placeholder={placeholder || 'More Options Available'} + placeholder={placeholder || feWords.moreOptions} minimal icon='ListView' showDescription={false} @@ -4877,7 +4907,7 @@ const FormEngineImpl = ({ delay: 300, content: ( ), @@ -4938,14 +4968,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 +4989,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 +4997,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 +5046,7 @@ const FormEngineImpl = ({ { icon: 'HistoryLine', className: 'fields-revert', - tooltip: 'Revert Changes', + tooltip: feWords.revertChangesTitle, minimal: true, position: 'right', show: @@ -5030,7 +5060,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/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/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..1e4a8d8d 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; @@ -219,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[]; @@ -243,6 +213,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 +230,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 +261,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, @@ -423,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); @@ -1256,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); @@ -1268,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; 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/qorusTypeCatalogue.ts b/src/helpers/qorusTypeCatalogue.ts index 9595ad00..b4bcdbba 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,21 @@ 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 = Array.from( + 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 new file mode 100644 index 00000000..973db6de --- /dev/null +++ b/src/helpers/reqraftLabels.ts @@ -0,0 +1,702 @@ +// 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; + /** 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; + 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 ')})`, + typesJoin: (types) => types.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,