@ux7suit/create-ux7-app 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,22 @@
1
+ # create-ux7-app
2
+
3
+ UX7 Suit platformu için Module Federation tabanlı bir demo/app scaffold aracı.
4
+
5
+ ```
6
+ npm create ux7-app@latest my-app
7
+ cd my-app
8
+ npm install
9
+ npm run dev
10
+ ```
11
+
12
+ Bu, [UX7 Suit](https://ux7suit.com) shell'i içine bağımsız BSP olarak deploy edilecek,
13
+ CONTRACT.md sözleşmesine uygun CRUD + Module Federation remote yapısına sahip minimal
14
+ bir uygulama iskeleti üretir — `quickdemo` pilot uygulamasından türetilmiştir (create/read/
15
+ update/delete, optimistic locking, validation, sayfalama).
16
+
17
+ Üretilen projenin kendi `README.md`'si, backend (ABAP) tarafında ne yazman gerektiğini ve
18
+ `fiori deploy` ile nasıl deploy edeceğini anlatır.
19
+
20
+ Bu paket `@ux7suit/react`, `@ux7suit/sdk`, `@ux7suit/workspace` paketlerini peer olarak
21
+ kullanır; sürüm uyumu için UX7 Suit shell'inin güncel "Shell API compatibility matrix"ine
22
+ bakın (platform yönetim panelinizde veya UX7 Suit ekibinden).
package/bin.mjs ADDED
@@ -0,0 +1,102 @@
1
+ #!/usr/bin/env node
2
+ // create-ux7-app — UX7 Suit platformu için Module Federation demo/app şablonu scaffold eder.
3
+ // Kullanım: npm create ux7-app@latest my-app [-- --title "Görünen ad"]
4
+ //
5
+ // scripts/create-app.mjs'in (UX7 monorepo içi, kendi ekip için) genel/dış geliştirici
6
+ // karşılığıdır. Aradaki fark: bu paket monorepo DIŞINDA, herhangi bir müşteri/3. parti
7
+ // projesinde çalışacak şekilde kendi kendine yeterli — ../../scripts/federation gibi
8
+ // monorepo'ya özel relative import'lar burada yok, template kendi başına ayakta durur.
9
+
10
+ import fs from 'node:fs'
11
+ import path from 'node:path'
12
+ import { fileURLToPath } from 'node:url'
13
+
14
+ const HERE = path.dirname(fileURLToPath(import.meta.url))
15
+ const TEMPLATE_DIR = path.join(HERE, 'template')
16
+
17
+ function usage() {
18
+ console.log(`Kullanım: npm create ux7-app@latest <klasör-adı> [-- --title "Görünen Ad"]
19
+
20
+ Örnek:
21
+ npm create ux7-app@latest stok-sayim -- --title "Stok Sayım"
22
+
23
+ <klasör-adı> aynı zamanda uygulama kimliği (app id) olarak kullanılır:
24
+ - Endpoint kökü: /<app-id>/records
25
+ - Module Federation remote adı: ux7_<app-id altçizgili>
26
+ - BSP adı önerisi: ZUX7_<APP-ID BÜYÜK, altçizgili>
27
+
28
+ App id kuralı: 2-31 karakter, küçük harf/rakam/tire, harfle başlamalı (örn. "stok-sayim").`)
29
+ }
30
+
31
+ function toAppId(raw) {
32
+ return String(raw).trim().toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '')
33
+ }
34
+
35
+ function toTitle(appId) {
36
+ return appId.split('-').map(part => part.charAt(0).toUpperCase() + part.slice(1)).join(' ')
37
+ }
38
+
39
+ function copyTemplate(srcDir, destDir, replacements) {
40
+ fs.mkdirSync(destDir, { recursive: true })
41
+ for (const entry of fs.readdirSync(srcDir, { withFileTypes: true })) {
42
+ const srcPath = path.join(srcDir, entry.name)
43
+ const destName = entry.name.replace(/__APP_ID__/g, replacements.APP_ID)
44
+ const destPath = path.join(destDir, destName)
45
+ if (entry.isDirectory()) {
46
+ copyTemplate(srcPath, destPath, replacements)
47
+ continue
48
+ }
49
+ const raw = fs.readFileSync(srcPath, 'utf8')
50
+ const content = raw
51
+ .replaceAll('__APP_ID_SNAKE__', replacements.APP_ID_SNAKE)
52
+ .replaceAll('__APP_ID_UPPER__', replacements.APP_ID_UPPER)
53
+ .replaceAll('__APP_ID__', replacements.APP_ID)
54
+ .replaceAll('__APP_TITLE__', replacements.APP_TITLE)
55
+ fs.writeFileSync(destPath, content)
56
+ }
57
+ }
58
+
59
+ function main() {
60
+ const argv = process.argv.slice(2)
61
+ if (!argv.length || argv.includes('-h') || argv.includes('--help')) return usage(), process.exit(argv.length ? 0 : 1)
62
+
63
+ const titleIndex = argv.indexOf('--title')
64
+ const titleArg = titleIndex >= 0 ? argv[titleIndex + 1] : undefined
65
+ const positional = argv.filter((value, index) => index !== titleIndex && index !== titleIndex + 1)
66
+ const targetArg = positional[0]
67
+ if (!targetArg) return usage(), process.exit(1)
68
+
69
+ const appId = toAppId(targetArg)
70
+ if (!/^[a-z][a-z0-9-]{1,30}$/.test(appId)) {
71
+ console.error(`Geçersiz app id türetildi: "${appId}". 2-31 küçük harf/rakam/tire, harfle başlamalı.`)
72
+ process.exit(1)
73
+ }
74
+
75
+ const targetDir = path.resolve(process.cwd(), targetArg)
76
+ if (fs.existsSync(targetDir) && fs.readdirSync(targetDir).length > 0) {
77
+ console.error(`"${targetArg}" boş değil. Farklı/boş bir klasör adı verin.`)
78
+ process.exit(1)
79
+ }
80
+
81
+ const replacements = {
82
+ APP_ID: appId,
83
+ APP_ID_SNAKE: appId.replaceAll('-', '_'),
84
+ APP_ID_UPPER: appId.replaceAll('-', '_').toUpperCase(),
85
+ APP_TITLE: titleArg || toTitle(appId),
86
+ }
87
+
88
+ copyTemplate(TEMPLATE_DIR, targetDir, replacements)
89
+
90
+ console.log(`\n"${targetArg}" oluşturuldu (app id: ${replacements.APP_ID}).\n`)
91
+ console.log(`Sıradaki adımlar:
92
+ cd ${targetArg}
93
+ npm install
94
+ npm run dev # standalone dev sunucusu (UX7 Suit shell olmadan, izole test için)
95
+
96
+ Devam etmeden önce README.md'yi oku — şunları senin dolduracağın: backend ABAP handler'ı
97
+ (zcl_ux7_handler'dan REDEFINITION), deploy/ui5-deploy.dev.yaml'daki SAP sistem/paket/transport
98
+ bilgileri, ve vite.config.ts'teki paylaşılan paket sürümlerinin UX7 Suit shell'inle uyuşması
99
+ (strictVersion) — bu üçü olmadan uygulama shell içinde çalışmaz.`)
100
+ }
101
+
102
+ main()
package/package.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "@ux7suit/create-ux7-app",
3
+ "version": "0.1.0",
4
+ "description": "UX7 Suit platformu için Module Federation tabanlı demo/app scaffold aracı. `npm create ux7-app@latest my-app` ile çalışır.",
5
+ "type": "module",
6
+ "license": "UNLICENSED",
7
+ "bin": {
8
+ "create-ux7-app": "./bin.mjs"
9
+ },
10
+ "files": [
11
+ "bin.mjs",
12
+ "template"
13
+ ],
14
+ "engines": {
15
+ "node": ">=18"
16
+ },
17
+ "publishConfig": {
18
+ "access": "public"
19
+ }
20
+ }
@@ -0,0 +1,63 @@
1
+ # __APP_TITLE__
2
+
3
+ `create-ux7-app` ile üretildi. UX7 Suit shell'ine Module Federation remote olarak
4
+ bağımsız bir BSP şeklinde deploy edilecek bir CRUD uygulaması iskeleti.
5
+
6
+ ## Bu şablonda ne var
7
+
8
+ - `src/api.ts` — `__APP_ID__/records` entity'si için list/read/create/update/delete;
9
+ optimistic locking (`version`), envelope/hata sözleşmesi (`data`, `messages`, 409 conflict).
10
+ - `src/RecordList.tsx`, `src/RecordEditor.tsx`, `src/RecordFields.tsx` — liste, filtre,
11
+ sayfalama, kayıt formu, sil/güncelle/versiyon-çakışması akışı.
12
+ - `src/App.tsx` — route'lar + `QueryClientProvider`; Module Federation `exposes`'i
13
+ (`vite.config.ts`) bu dosyayı dışa veriyor.
14
+
15
+ Bu, kasıtlı olarak budanmış bir örnek: toplu işlem (batch create/update/delete) ve
16
+ value-help gibi ileri desenler yok. Onları görmek isterseniz UX7 Suit ekibindeki
17
+ `quickdemo`/`bankint` pilot uygulamalarına ve `docs/BANKINT-DEEP-UYGULAMA.md`'ye bakın
18
+ (atomik çoklu-kayıt `actions/{action}` deseni — "deep post" — orada).
19
+
20
+ ## Kendi entity'nize uyarlama
21
+
22
+ 1. `src/api.ts`'te `RecordInput`/`RecordItem`'ı gerçek alanlarınızla değiştirin,
23
+ `validate()`/`payload()`'ı buna göre güncelleyin.
24
+ 2. `src/RecordFields.tsx` ve `src/RecordList.tsx`'teki kolonları eşleyin.
25
+ 3. `ENDPOINT` sabiti `/__APP_ID__/records` — backend'inizin gerçek entity adıyla eşleşsin.
26
+
27
+ ## Backend (ABAP) — sizin yazmanız gereken kısım
28
+
29
+ Bu CLI backend kodu üretmez. `docs/CONTRACT.md`'ye göre, `zcl_ux7_handler` soyut
30
+ sınıfından türeyen bir ABAP handler yazmanız gerekiyor (örn. `ZCL_UX7___APP_ID_UPPER__`),
31
+ şu metodları `REDEFINITION` ile dolduran: `list`, `read`, `create`, `update`,
32
+ `delete_rec`, `action`, `valuehelp`, `check_authority`. Menüde görünürlük ile yetki
33
+ kontrolü ayrı şeylerdir — `check_authority` her zaman kendi başına çalışmalı.
34
+
35
+ ## Dev / build / deploy
36
+
37
+ ```
38
+ npm install
39
+ npm run dev # standalone dev sunucusu — shell OLMADAN izole test için
40
+ npm run build # Module Federation remote build (remoteEntry.js dahil)
41
+ npm run deploy:dev # fiori deploy ile Dev'e BSP olarak deploy
42
+ ```
43
+
44
+ `npm run dev` shell'in sağladığı singleton'lar (React, `@ux7suit/react`, `@ux7suit/sdk`)
45
+ olmadan çalışır — component'leri izole görmek için yeterli, ama gerçek entegrasyon testi
46
+ uygulamayı shell içine (Module Federation remote olarak) yükleyip görmektir.
47
+
48
+ Deploy'dan önce `deploy/ui5-deploy.dev.yaml`'daki `TODO` alanlarını (Dev sistem URL'i,
49
+ ABAP paketi, transport request) doldurun. QA/Prod'a geçiş, standart Fiori transport
50
+ zinciriyle (STMS release + import) ilerler — bu CLI/proje QA/Prod'a yeniden deploy etmez.
51
+
52
+ ## Sürüm uyumu (önemli)
53
+
54
+ `vite.config.ts`'teki `SHARED_VERSIONS` UX7 Suit shell'inin kendi paylaşılan bağımlılık
55
+ sürümleriyle birebir eşleşmeli (Module Federation `strictVersion: true`). Uyuşmazlık
56
+ build zamanında değil, kullanıcı bu app'i shell içinde açtığında ortaya çıkar. Shell
57
+ güncellendiğinde bu app'in de yeniden build/deploy edilmesi gerekebilir — güncel
58
+ sürümleri UX7 Suit platform yöneticinizden alın.
59
+
60
+ ## Şablonu shell'in katalogunda görünür kılma
61
+
62
+ Bu uygulamayı deploy etmek onu otomatik olarak shell'in katalogunda görünür yapmaz —
63
+ katalog/registry kaydı ayrı bir adım, shell'i yöneten ekiple koordine edilmeli.
@@ -0,0 +1,20 @@
1
+ specVersion: "3.1"
2
+ metadata:
3
+ name: com.ux7.__APP_ID_SNAKE__
4
+ type: application
5
+ builder:
6
+ customTasks:
7
+ - name: deploy-to-abap
8
+ afterTask: generateCachebusterInfo
9
+ configuration:
10
+ target:
11
+ # TODO: Dev sisteminizin ICF URL'i (örn. http://<host>:<port>)
12
+ url: ""
13
+ client: "100"
14
+ app:
15
+ name: ZUX7___APP_ID_UPPER__
16
+ description: __APP_TITLE__
17
+ # TODO: uygulamanın gideceği ABAP paketi
18
+ package: ""
19
+ # TODO: Dev transport request'iniz
20
+ transport: ""
@@ -0,0 +1,3 @@
1
+ <!doctype html>
2
+ <html lang="tr"><head><meta charset="UTF-8"><title>__APP_TITLE__</title></head>
3
+ <body><p>Bu uygulamayı UX7 Suit shell içindeki "__APP_TITLE__" kutucuğundan açın.</p></body></html>
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "__APP_ID__",
3
+ "private": true,
4
+ "version": "0.1.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "tsc --noEmit && vite build",
9
+ "preview": "vite preview",
10
+ "typecheck": "tsc --noEmit",
11
+ "deploy:dev": "npm run build && fiori deploy --config deploy/ui5-deploy.dev.yaml --yes && rimraf archive.zip"
12
+ },
13
+ "dependencies": {
14
+ "@tanstack/react-query": "^5.62.7",
15
+ "@ux7suit/react": "^0.2.0",
16
+ "@ux7suit/sdk": "^0.1.0",
17
+ "@ux7suit/workspace": "^0.1.0",
18
+ "react": "^19.0.0",
19
+ "react-dom": "^19.0.0",
20
+ "react-router": "^7.1.1"
21
+ },
22
+ "devDependencies": {
23
+ "@module-federation/vite": "^1.22.1",
24
+ "@sap/ux-ui5-tooling": "^1",
25
+ "@types/react": "^19.0.2",
26
+ "@types/react-dom": "^19.0.2",
27
+ "@vitejs/plugin-react": "^4.3.4",
28
+ "rimraf": "^6",
29
+ "typescript": "~5.7.2",
30
+ "vite": "^6.0.5"
31
+ }
32
+ }
@@ -0,0 +1,23 @@
1
+ import { useState } from 'react'
2
+ import { Route, Routes } from 'react-router'
3
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
4
+ import { isApiError } from '@ux7suit/sdk'
5
+ import { StatusScreen } from '@ux7suit/workspace'
6
+ import { RecordList } from './RecordList'
7
+ import { RecordEditor } from './RecordEditor'
8
+ import '@ux7suit/workspace/styles.css'
9
+
10
+ export default function App() {
11
+ const [client] = useState(() => new QueryClient({ defaultOptions: {
12
+ queries: { staleTime: 30_000, gcTime: 60_000, refetchOnWindowFocus: false, retry: (count, error) => count < 1 && isApiError(error) && error.kind === 'network' },
13
+ mutations: { retry: false },
14
+ } }))
15
+ return <QueryClientProvider client={client}><div style={{ maxWidth: 1400, margin: '0 auto', padding: 'var(--ux7-sp-4)' }}>
16
+ <Routes>
17
+ <Route index element={<RecordList />} />
18
+ <Route path="new" element={<RecordEditor creating />} />
19
+ <Route path=":id" element={<RecordEditor />} />
20
+ <Route path="*" element={<StatusScreen title="__APP_TITLE__ sayfası bulunamadı" />} />
21
+ </Routes>
22
+ </div></QueryClientProvider>
23
+ }
@@ -0,0 +1,66 @@
1
+ import { useState } from 'react'
2
+ import { useNavigate, useParams } from 'react-router'
3
+ import { useQuery, useQueryClient } from '@tanstack/react-query'
4
+ import { Button, ConfirmDialog, Page, Toolbar } from '@ux7suit/react'
5
+ import { isApiError } from '@ux7suit/sdk'
6
+ import { UnsavedChanges, useUnsavedChanges } from '@ux7suit/workspace'
7
+ import { recordApi, errorText, validate, type RecordInput, type RecordItem } from './api'
8
+ import { EMPTY, RecordFields } from './RecordFields'
9
+
10
+ export function RecordEditor({ creating = false }: { creating?: boolean }) {
11
+ const { id = '' } = useParams()
12
+ const query = useQuery({ queryKey: ['__APP_ID__', 'record', id], queryFn: ({ signal }) => recordApi.read(id, signal), enabled: !creating, refetchOnWindowFocus: false })
13
+ if (!creating && query.isPending) return <p role="status">Kayıt yükleniyor…</p>
14
+ if (!creating && query.isError) return <div role="alert">{errorText(query.error)} <Button onClick={() => void query.refetch()}>Tekrar dene</Button></div>
15
+ return <Editor key={`${creating ? 'new' : id}:${query.data?.version ?? ''}`} initial={query.data} creating={creating} refresh={() => void query.refetch()} />
16
+ }
17
+
18
+ function Editor({ initial, creating, refresh }: { initial: RecordItem | undefined; creating: boolean; refresh: () => void }) {
19
+ const navigate = useNavigate()
20
+ const client = useQueryClient()
21
+ const [value, setValue] = useState<RecordInput>(initial ?? EMPTY)
22
+ const [errors, setErrors] = useState<Record<string, string>>({})
23
+ const [error, setError] = useState('')
24
+ const [conflict, setConflict] = useState(false)
25
+ const [busy, setBusy] = useState(false)
26
+ const [deleting, setDeleting] = useState(false)
27
+ const [reload, setReload] = useState(false)
28
+ const dirty = JSON.stringify(value) !== JSON.stringify(initial ?? EMPTY)
29
+ const guard = useUnsavedChanges(dirty || busy)
30
+ async function save() {
31
+ const nextErrors = validate(value); setErrors(nextErrors); setError(''); setConflict(false)
32
+ if (Object.keys(nextErrors).length || busy) return
33
+ setBusy(true)
34
+ try {
35
+ const saved = creating ? await recordApi.create(value) : await recordApi.update(initial!, value)
36
+ client.setQueryData(['__APP_ID__', 'record', saved.id], saved)
37
+ await client.invalidateQueries({ queryKey: ['__APP_ID__', 'list'] })
38
+ guard.allowNavigation(); navigate('..')
39
+ } catch (cause) {
40
+ setError(errorText(cause)); setConflict(isApiError(cause) && cause.kind === 'conflict')
41
+ if (isApiError(cause)) setErrors(Object.fromEntries(cause.messages.filter(message => message.field).map(message => [message.field!, message.text])))
42
+ } finally { setBusy(false) }
43
+ }
44
+ async function remove() {
45
+ if (!initial || busy) return
46
+ setBusy(true)
47
+ try { await recordApi.remove(initial); client.removeQueries({ queryKey: ['__APP_ID__', 'record', initial.id], exact: true }); await client.invalidateQueries({ queryKey: ['__APP_ID__', 'list'] }); guard.allowNavigation(); navigate('..') }
48
+ catch (cause) { setError(errorText(cause)); setDeleting(false) }
49
+ finally { setBusy(false) }
50
+ }
51
+ return <Page title={creating ? 'Yeni kayıt' : `Kayıt ${initial!.id}`}>
52
+ <UnsavedChanges dirty={dirty || busy} guard={guard} />
53
+ {error && <div role="alert"><p>{error}</p>{conflict && <Button onClick={() => setReload(true)}>Güncel kaydı yükle</Button>}</div>}
54
+ <form onSubmit={event => { event.preventDefault(); void save() }}>
55
+ <fieldset disabled={busy} className="ux7-work-fieldset"><RecordFields value={value} onChange={setValue} errors={errors} creating={creating} /></fieldset>
56
+ {initial && <p>Son değiştiren: {initial.changedBy || '—'} · Sürüm: {initial.version || 'Yok'} · Oluşturan: {initial.createdBy || '—'}</p>}
57
+ <Toolbar>
58
+ <Button type="submit" variant="primary" disabled={busy || (!creating && !initial?.version)}>{busy ? 'İşleniyor…' : 'Kaydet'}</Button>
59
+ {!creating && <Button type="button" disabled={busy || !initial?.version} onClick={() => setDeleting(true)}>Sil</Button>}
60
+ <Button type="button" disabled={busy} onClick={() => navigate('..')}>Listeye dön</Button>
61
+ </Toolbar>
62
+ </form>
63
+ <ConfirmDialog open={deleting} title="Kaydı sil" confirmLabel="Sil" busy={busy} onCancel={() => setDeleting(false)} onConfirm={() => void remove()}>Yalnızca {initial?.id} kaydı silinecek. Bu işlem geri alınamaz.</ConfirmDialog>
64
+ <ConfirmDialog open={reload} title="Güncel kaydı yükle" confirmLabel="Taslağı bırak ve yenile" onCancel={() => setReload(false)} onConfirm={() => { setReload(false); refresh() }}>Girdiğiniz değişiklikler bırakılıp sunucudaki kayıt yüklenecek.</ConfirmDialog>
65
+ </Page>
66
+ }
@@ -0,0 +1,14 @@
1
+ import { Checkbox, Field, FormLayout, TextInput } from '@ux7suit/react'
2
+ import type { RecordInput } from './api'
3
+
4
+ export const EMPTY: RecordInput = { name: '', city: '', amount: '0.00', currency: 'TRY', active: true }
5
+
6
+ export function RecordFields({ value, onChange, errors, creating = false }: { value: RecordInput; onChange: (value: RecordInput) => void; errors: Record<string, string>; creating?: boolean }) {
7
+ const fields = [...(creating ? [['id', 'ID (boşsa sunucu üretir)', 10] as const] : []), ['name', 'Ad', 60], ['city', 'Şehir', 100], ['amount', 'Tutar', 14], ['currency', 'Para birimi', 5]] as const
8
+ return <FormLayout>
9
+ {fields.map(([key, label, max]) => <Field key={key} name={key} label={label} required={key === 'name' || key === 'amount' || key === 'currency'} messages={errors[key] ? [{ type: 'E', text: errors[key]!, code: key }] : []}>
10
+ <TextInput value={String(value[key as keyof RecordInput] ?? '')} maxLength={max} onChange={event => onChange({ ...value, [key]: key === 'currency' || key === 'id' ? event.target.value.toUpperCase() : event.target.value })} />
11
+ </Field>)}
12
+ <Checkbox checked={value.active} onCheckedChange={active => onChange({ ...value, active })}>Aktif</Checkbox>
13
+ </FormLayout>
14
+ }
@@ -0,0 +1,47 @@
1
+ import { useEffect, useState } from 'react'
2
+ import { useNavigate } from 'react-router'
3
+ import { useQuery } from '@tanstack/react-query'
4
+ import { Button, Page, TextInput, Toolbar, textColumn } from '@ux7suit/react'
5
+ import { DEFAULT_LIST, WorkspaceTable, useListWorkspace } from '@ux7suit/workspace'
6
+ import { recordApi, errorText, type RecordItem } from './api'
7
+
8
+ const columns = [
9
+ textColumn<RecordItem>({ id: 'id', header: 'ID', accessor: row => row.id, width: 140 }),
10
+ textColumn<RecordItem>({ id: 'name', header: 'Ad', accessor: row => row.name, width: 240 }),
11
+ textColumn<RecordItem>({ id: 'city', header: 'Şehir', accessor: row => row.city, width: 160 }),
12
+ textColumn<RecordItem>({ id: 'amount', header: 'Tutar', accessor: row => String(row.amount), width: 130 }),
13
+ textColumn<RecordItem>({ id: 'currency', header: 'Para birimi', accessor: row => row.currency, width: 110 }),
14
+ textColumn<RecordItem>({ id: 'active', header: 'Aktif', accessor: row => row.active ? 'Evet' : 'Hayır', width: 90 }),
15
+ ]
16
+ const csvColumns = columns.map(column => ({ id: column.id!, label: String(column.header), value: (row: RecordItem) => row[column.id as keyof RecordItem] }))
17
+
18
+ export function RecordList() {
19
+ const navigate = useNavigate()
20
+ const workspace = useListWorkspace('__APP_ID__-records', DEFAULT_LIST)
21
+ const { state, change, setPage } = workspace
22
+ const [q, setQ] = useState(state.q)
23
+ const [active, setActive] = useState(state.filters.active ?? '')
24
+ const [jump, setJump] = useState('1')
25
+ const params = { page: state.page, size: state.size, q: state.q, active: state.filters.active ?? '' }
26
+ const query = useQuery({ queryKey: ['__APP_ID__', 'list', params], queryFn: ({ signal }) => recordApi.list(params, signal) })
27
+ const rows = query.data?.rows ?? []
28
+ const total = query.data?.total ?? 0
29
+ const pages = Math.max(1, Math.ceil(total / state.size))
30
+ useEffect(() => { if (query.data && state.page > pages) setPage(pages) }, [query.data, state.page, pages])
31
+ return <Page title="__APP_TITLE__" subtitle={`${total.toLocaleString('tr-TR')} kayıt`} actions={<Button onClick={() => navigate('new')}>Yeni kayıt</Button>}>
32
+ <form onSubmit={event => { event.preventDefault(); change({ q: q.trim(), filters: { active } }) }}>
33
+ <Toolbar>
34
+ <TextInput aria-label="Ada göre ara" placeholder="Ada göre ara" maxLength={60} value={q} onChange={event => setQ(event.target.value)} />
35
+ <select className="ux7-native-select" aria-label="Aktiflik filtresi" value={active} onChange={event => setActive(event.target.value)}><option value="">Tümü</option><option value="true">Aktif</option><option value="false">Pasif</option></select>
36
+ <Button type="submit" disabled={query.isFetching}>Filtrele</Button>
37
+ <Button type="button" onClick={() => { setQ(''); setActive(''); change({ q: '', filters: {} }) }}>Temizle</Button>
38
+ <Button type="button" disabled={query.isFetching} onClick={() => void query.refetch()}>Yenile</Button>
39
+ </Toolbar>
40
+ </form>
41
+ {query.isError && <p role="alert">{errorText(query.error)}</p>}
42
+ <WorkspaceTable id="__APP_ID__-records" columns={columns} csvColumns={csvColumns} rows={rows} rowId={row => row.id} workspace={workspace} total={total} loading={query.isPending} fetching={query.isFetching} sortable={false} onRowClick={row => navigate(encodeURIComponent(row.id))} />
43
+ <form onSubmit={event => { event.preventDefault(); const page = Number(jump); if (Number.isInteger(page) && page >= 1 && page <= pages) setPage(page) }}>
44
+ <Toolbar><TextInput type="number" aria-label="Gidilecek sayfa" min={1} max={pages} value={jump} onChange={event => setJump(event.target.value)} /><Button type="submit" disabled={query.isFetching}>Sayfaya git</Button><Button type="button" disabled={query.isFetching || state.page === pages} onClick={() => setPage(pages)}>Son sayfa</Button></Toolbar>
45
+ </form>
46
+ </Page>
47
+ }
@@ -0,0 +1,58 @@
1
+ import { ApiError, http, isApiError, toCamelKeysDeep, type Envelope } from '@ux7suit/sdk'
2
+
3
+ // Örnek entity: 5 alanlı bir kayıt (name/city/amount/currency/active). Kendi entity'nize
4
+ // göre RecordInput/RecordItem'ı ve validate()/payload()'ı değiştirin — envelope, versiyon
5
+ // ve hata sözleşmesi (bkz. CONTRACT.md) aynı kalmalı.
6
+ export const ENDPOINT = '/__APP_ID__/records'
7
+ export type RecordInput = { id?: string; name: string; city: string; amount: string | number; currency: string; active: boolean }
8
+ export type RecordItem = RecordInput & { id: string; version: string; changedAt?: string; changedBy?: string; createdAt?: string; createdBy?: string }
9
+ export type ListParams = { page: number; size: number; q: string; active: string }
10
+
11
+ export function errorText(error: unknown): string {
12
+ if (isApiError(error) && error.messages.length) return error.messages.map(message => message.text).join('\n')
13
+ return error instanceof Error ? error.message : String(error)
14
+ }
15
+
16
+ function checked<T>(env: Envelope<T>): Envelope<T> {
17
+ const errors = env.messages.filter(message => message.type === 'E' || message.type === 'A')
18
+ if (errors.length) throw new ApiError({ kind: 'http', status: 422, method: 'WRITE', url: ENDPOINT, message: errors.map(message => message.text).join('\n'), messages: errors })
19
+ return env
20
+ }
21
+
22
+ function record(env: Envelope<RecordItem>): RecordItem {
23
+ const result = toCamelKeysDeep(checked(env).data) as RecordItem
24
+ if (!result?.id) throw new Error('Sunucu geçerli bir kayıt döndürmedi.')
25
+ return { ...result, version: env.version || result.version || '' }
26
+ }
27
+
28
+ export function validate(input: RecordInput): Record<string, string> {
29
+ const errors: Record<string, string> = {}
30
+ if (input.id && !/^[A-Za-z0-9_-]{1,10}$/.test(input.id)) errors.id = 'ID en fazla 10 harf, rakam, tire veya alt çizgi olmalı.'
31
+ if (!input.name.trim() || input.name.length > 60) errors.name = 'Ad zorunlu ve en fazla 60 karakter olmalı.'
32
+ if (input.city.length > 100) errors.city = 'Şehir en fazla 100 karakter olmalı.'
33
+ const amount = String(input.amount)
34
+ if (!/^\d{1,11}(\.\d{1,2})?$/.test(amount) || !Number.isFinite(Number(amount))) errors.amount = 'Tutar 0–99999999999.99 aralığında, en fazla iki ondalıklı olmalı.'
35
+ if (!/^[A-Z]{3,5}$/.test(input.currency)) errors.currency = 'Para birimi 3–5 büyük harf olmalı (örn. TRY).'
36
+ return errors
37
+ }
38
+
39
+ export function payload(input: RecordInput): RecordInput {
40
+ const errors = validate(input)
41
+ if (Object.keys(errors).length) throw new Error(Object.values(errors).join('\n'))
42
+ return { ...(input.id ? { id: input.id.toUpperCase() } : {}), name: input.name.trim(), city: input.city.trim(), amount: String(input.amount), currency: input.currency, active: input.active }
43
+ }
44
+
45
+ function path(id: string) { return `${ENDPOINT}/${encodeURIComponent(id)}` }
46
+ function requireVersion(row: RecordItem) { if (!row.version) throw new Error(`${row.id}: sürüm bilgisi yok. Kaydı yenileyin.`) }
47
+
48
+ export const recordApi = {
49
+ async list(params: ListParams, signal?: AbortSignal) {
50
+ const env = checked(await http.get<RecordItem[]>(ENDPOINT, { params, ...(signal ? { signal } : {}) }))
51
+ if (!Array.isArray(env.data) || !env.page || !Number.isSafeInteger(env.page.total) || env.page.total < 0) throw new Error('Sunucu sayfalama sözleşmesi geçersiz; data dizisi ve page.total bekleniyor.')
52
+ return { rows: env.data, total: env.page.total }
53
+ },
54
+ async read(id: string, signal?: AbortSignal) { return record(await http.get<RecordItem>(path(id), signal ? { signal } : undefined)) },
55
+ async create(input: RecordInput) { return record(await http.post<RecordItem>(ENDPOINT, payload(input))) },
56
+ async update(row: RecordItem, input: RecordInput) { requireVersion(row); return record(await http.patch<RecordItem>(path(row.id), payload(input), { version: row.version })) },
57
+ async remove(row: RecordItem) { requireVersion(row); checked(await http.del(path(row.id), { version: row.version })) },
58
+ }
@@ -0,0 +1,29 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "useDefineForClassFields": true,
5
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
6
+ "module": "ESNext",
7
+ "skipLibCheck": true,
8
+
9
+ "moduleResolution": "bundler",
10
+ "allowImportingTsExtensions": true,
11
+ "resolveJsonModule": true,
12
+ "verbatimModuleSyntax": true,
13
+ "moduleDetection": "force",
14
+ "noEmit": true,
15
+ "jsx": "react-jsx",
16
+
17
+ "strict": true,
18
+ "noUnusedLocals": true,
19
+ "noUnusedParameters": true,
20
+ "noFallthroughCasesInSwitch": true,
21
+ "noUncheckedSideEffectImports": true,
22
+ "exactOptionalPropertyTypes": true,
23
+ "noImplicitOverride": true,
24
+ "noImplicitReturns": true,
25
+
26
+ "types": ["vite/client"]
27
+ },
28
+ "include": ["src"]
29
+ }
@@ -0,0 +1,30 @@
1
+ import { defineConfig } from 'vite'
2
+ import react from '@vitejs/plugin-react'
3
+ import { federation } from '@module-federation/vite'
4
+ import { fileURLToPath } from 'node:url'
5
+
6
+ // ÖNEMLİ: bu sürümler UX7 Suit shell'inin kendi "Shell API compatibility matrix"i
7
+ // (shell'in scripts/federation.ts -> sharedDependencies()) ile BİREBİR aynı olmalı.
8
+ // Uyuşmazlık npm install zamanında değil, kullanıcı bu app'i shell içinde açtığında,
9
+ // Module Federation'ın strictVersion reddiyle ortaya çıkar. Güncel sürümleri UX7 Suit
10
+ // platform yöneticinizden / shell dokümantasyonundan alın ve burada güncelleyin.
11
+ const SHARED_VERSIONS: Record<string, string> = {
12
+ react: '^19.0.0', 'react/jsx-runtime': '^19.0.0',
13
+ 'react-dom': '^19.0.0', 'react-dom/client': '^19.0.0',
14
+ 'react-router': '^7.1.1', '@tanstack/react-query': '^5.62.7',
15
+ '@ux7suit/react': '0.2.0', '@ux7suit/sdk': '0.1.0',
16
+ }
17
+ const shared = Object.fromEntries(Object.entries(SHARED_VERSIONS).map(([name, requiredVersion]) => [name, {
18
+ singleton: true, strictVersion: true, requiredVersion, import: false as const,
19
+ }]))
20
+
21
+ export default defineConfig(({ command }) => ({
22
+ // TODO: deploy sonrası gerçek BSP path'iniz farklıysa güncelleyin.
23
+ base: command === 'serve' ? '/sap/bc/ui5_ui5/sap/zux7___APP_ID_SNAKE__/' : './',
24
+ plugins: [react(), federation({
25
+ name: 'ux7___APP_ID_SNAKE__', filename: 'remoteEntry.js',
26
+ exposes: { './App': fileURLToPath(new URL('./src/App.tsx', import.meta.url)) },
27
+ shared, dts: false, dev: false, bundleAllCSS: true,
28
+ })],
29
+ build: { target: 'esnext', outDir: 'dist', sourcemap: false },
30
+ }))